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.


doyle is available. everyone's on legacy spt: `$OWL send`


Check Doyle availability and legacy SPT send


Full plan read — 4 waves, W1 = activity-gated routing substrate + fault-transient re-spool. Read the design authority (ADR-0028) + the core router anchor (delivery.rs):


1	# Activity-gated message delivery + send-modifier axes
2
3	<!-- [doc->REQ-MSG-DELIVERY-AXES] -->
4
5	## Status
6
7	accepted (2026-06-23; design grilled with operator + `doyle`, `/grill-with-docs`). Targets **v0.15.0** (the milestone immediately after v0.14.3). Builds on ADR-0020 (the `<EVENT>` envelope as the sole arriving format), ADR-0022 + its v0.14.2 amendment (translation-binary idle delivery; raw-inject removed), and the activity/idle sentinel already shipped (`api state <busy|idle>`). CONTEXT.md "activity-gated delivery" / "message delivery axes" / "message metadata (`json`)" mint this vocabulary.
8
9	## Context
10
11	Activity-gated inbound delivery is a **legacy-SPT parity gap that was never written down** (absent from CONTEXT.md and the roadmap). The pieces are half-built: `api state busy|idle` writes the `.idle` sentinel, `delivery::is_idle(id)` reads it, and `resolve_inject_methods(manifest, idle)` maps idle-state → inject methods — but the result is discarded (`let _methods = …`), and `broker::dispatch_endpoint_input` injects unconditionally, its own comment calling activity-gated routing "a deferred follow wave." v0.14.3 (the ADR-0022 amendment) just removed the silent raw-inject fallback, so a missing/failed translation binary now spools loudly — sharpening the question of *when* and *through what* a message should reach an endpoint.
12
13	The operator needs per-message control over that delivery, beyond the single `--deferred` (spool-only, hook-channel) flag that exists today. The risk is modelling these as one flat list of mutually-exclusive "message types" — which conflates independent concerns (*when* vs *through what* vs *how long*) and forbids coherent combinations.
14
15	## Decision
16
17	**Complete the activity-gated delivery model, and expose per-message control as three orthogonal send-modifier axes plus an opaque metadata attribute** — not a flat "type" enum.
18
19	### Activity-gated delivery (the substrate v0.15.0 wires)
20
21	An inbound message has two possible delivery moments, chosen by the receiver's activity sentinel:
22
23	- **active window** — while the endpoint is active, the message spools for the receiver's own hook-poll to drain (non-disruptive).
24	- **idle window** — on idle (or an idle transition before a hook drains it), it delivers immediately: translation binary (spt-hosted) → relay-poll (either topology) → spool, in that fallback order.
25
26	### Three orthogonal axes (each composes; each defaults to its unrestricted value)
27
28	1. **delivery window** (*when*) — mutually exclusive:
29	   - **default** — eligible for both windows; delivers in whichever fires first.
30	   - `--idle-only` — idle window only; delivered immediately if already idle.
31	   - `--active-only` — active window only (hook-poll); never wakes an idle agent. **This is the renamed `--deferred`** (pure rename — the `deferred=1` spool column and the adapter-facing `api poll --include-deferred` keep their names).
32	2. **channel restriction** (*through what*) — mutually exclusive, composes with the window:
33	   - **unrestricted** — any configured inject method.
34	   - `--prefer-native` — the translation binary if one is running, else fall back to the standard methods.
35	   - `--force-native` — the translation binary and nothing else (no fallback, no spool-to-another-method).
36	   - "Native" = the `[message-idle-translation-binary]` PTY channel. The native flags do **not** respect the binary's idle-gating — the **window** flag dictates *when*, the native flag dictates *through what*. So `--force-native --active-only` = the binary injects during the active window, never idle (the InjectFloor already buffers controller input atomically, making mid-turn injection safe).
37	3. **persistence** (*how long it waits*) — mutually exclusive, composes:
38	   - **durable** (default) — spooled until delivered or TTL.
39	   - `--ephemeral` — dropped if it cannot deliver in its accepted window: at the moment the window opens with no live carrier, or at TTL, whichever is first.
40
41	### Metadata (orthogonal to all three axes)
42
43	- `--json-payload '<json>'` — attach an opaque JSON metadata block, carried as a single attr-escaped `json="…"` envelope attribute **alongside** (never replacing) the body. spt-core never interprets it — pure verbatim passthrough across every rail (spool/TCP/WAN/EVENT-PART); the receiving adapter (hooks and/or translation binary) parses it. Available to **any** sender (it confers no spt-core authority).
44
45	### Hazard reconciliation
46
47	`--ephemeral` is the **only** path permitted to drop a message silently — the explicit, sender-opted-in exception to **REQ-HAZARD-IDLE-SILENT-NONDELIVERY** (shipped in v0.14.3). Every non-ephemeral path still spools and reports `delivered=false`. The in-flight hazard gains one clause: *"…unless the sender set `--ephemeral`."*
48
49	## Considered Options
50
51	- **One flat list of mutually-exclusive "message types"** (`--idle-only` / `--active-only` / `--native-only`) — the operator's first framing. Rejected: it conflates an activity *gate* (idle/active) with a *channel* selector (native), forbidding coherent combinations (e.g. binary-during-active) and making `--native-only`'s "regardless of idle" impossible to express. Decomposed into the three orthogonal axes above.
52	- **Native flags respect the binary's idle-gating** (Reading A) — `--force-native` then only ever fires on idle, making it near-redundant with the default's idle-window channel order and `--prefer-native` meaningless. Rejected for Reading B (native = channel restriction; window owns timing), the only reading where the native flags carry distinct, non-redundant meaning.
53	- **`--custom '<json>'` → multiple top-level XML attributes** — rejected in favour of `--json-payload` (single `json` attr). The multi-attr form opens an identity-forgery surface (`{"from":"the-user"}` becomes a real envelope attr), needs a reserved-set + a future-prefix policy to stay collision-safe, and is *less* expressive (flat strings vs nested/typed JSON). The single-attr blob is collision-proof by construction, simpler for spt-core, and easier for adapters to parse.
54	- **Name the immediate-delivery default** (`--now`/`--immediate`) — rejected; once native is its own axis, "deliver now regardless" is just the unnamed default. Fewer flags; "I didn't say when → send it now" is the least-surprising default.
55	- **`--ephemeral` bolted onto `--force-native`** — rejected for a standalone persistence axis: ephemerality is useful beyond native (e.g. `--idle-only --ephemeral` = a wake-up that's pointless if stale), and isolating it gives the hazard carve-out a single clean home.
56

# Activity-gated message delivery + send-modifier axes

<!-- [doc->REQ-MSG-DELIVERY-AXES] -->

## Status

accepted (2026-06-23; design grilled with operator + `doyle`, `/grill-with-docs`). Targets **v0.15.0** (the milestone immediately after v0.14.3). Builds on ADR-0020 (the `<EVENT>` envelope as the sole arriving format), ADR-0022 + its v0.14.2 amendment (translation-binary idle delivery; raw-inject removed), and the activity/idle sentinel already shipped (`api state <busy|idle>`). CONTEXT.md "activity-gated delivery" / "message delivery axes" / "message metadata (`json`)" mint this vocabulary.

## Context

Activity-gated inbound delivery is a **legacy-SPT parity gap that was never written down** (absent from CONTEXT.md and the roadmap). The pieces are half-built: `api state busy|idle` writes the `.idle` sentinel, `delivery::is_idle(id)` reads it, and `resolve_inject_methods(manifest, idle)` maps idle-state → inject methods — but the result is discarded (`let _methods = …`), and `broker::dispatch_endpoint_input` injects unconditionally, its own comment calling activity-gated routing "a deferred follow wave." v0.14.3 (the ADR-0022 amendment) just removed the silent raw-inject fallback, so a missing/failed translation binary now spools loudly — sharpening the question of *when* and *through what* a message should reach an endpoint.

The operator needs per-message control over that delivery, beyond the single `--deferred` (spool-only, hook-channel) flag that exists today. The risk is modelling these as one flat list of mutually-exclusive "message types" — which conflates independent concerns (*when* vs *through what* vs *how long*) and forbids coherent combinations.

## Decision

**Complete the activity-gated delivery model, and expose per-message control as three orthogonal send-modifier axes plus an opaque metadata attribute** — not a flat "type" enum.

### Activity-gated delivery (the substrate v0.15.0 wires)

An inbound message has two possible delivery moments, chosen by the receiver's activity sentinel:

- **active window** — while the endpoint is active, the message spools for the receiver's own hook-poll to drain (non-disruptive).
- **idle window** — on idle (or an idle transition before a hook drains it), it delivers immediately: translation binary (spt-hosted) → relay-poll (either topology) → spool, in that fallback order.

### Three orthogonal axes (each composes; each defaults to its unrestricted value)

1. **delivery window** (*when*) — mutually exclusive:
   - **default** — eligible for both windows; delivers in whichever fires first.
   - `--idle-only` — idle window only; delivered immediately if already idle.
   - `--active-only` — active window only (hook-poll); never wakes an idle agent. **This is the renamed `--deferred`** (pure rename — the `deferred=1` spool column and the adapter-facing `api poll --include-deferred` keep their names).
2. **channel restriction** (*through what*) — mutually exclusive, composes with the window:
   - **unrestricted** — any configured inject method.
   - `--prefer-native` — the translation binary if one is running, else fall back to the standard methods.
   - `--force-native` — the translation binary and nothing else (no fallback, no spool-to-another-method).
   - "Native" = the `[message-idle-translation-binary]` PTY channel. The native flags do **not** respect the binary's idle-gating — the **window** flag dictates *when*, the native flag dictates *through what*. So `--force-native --active-only` = the binary injects during the active window, never idle (the InjectFloor already buffers controller input atomically, making mid-turn injection safe).
3. **persistence** (*how long it waits*) — mutually exclusive, composes:
   - **durable** (default) — spooled until delivered or TTL.
   - `--ephemeral` — dropped if it cannot deliver in its accepted window: at the moment the window opens with no live carrier, or at TTL, whichever is first.

### Metadata (orthogonal to all three axes)

- `--json-payload '<json>'` — attach an opaque JSON metadata block, carried as a single attr-escaped `json="…"` envelope attribute **alongside** (never replacing) the body. spt-core never interprets it — pure verbatim passthrough across every rail (spool/TCP/WAN/EVENT-PART); the receiving adapter (hooks and/or translation binary) parses it. Available to **any** sender (it confers no spt-core authority).

### Hazard reconciliation

`--ephemeral` is the **only** path permitted to drop a message silently — the explicit, sender-opted-in exception to **REQ-HAZARD-IDLE-SILENT-NONDELIVERY** (shipped in v0.14.3). Every non-ephemeral path still spools and reports `delivered=false`. The in-flight hazard gains one clause: *"…unless the sender set `--ephemeral`."*

## Considered Options

- **One flat list of mutually-exclusive "message types"** (`--idle-only` / `--active-only` / `--native-only`) — the operator's first framing. Rejected: it conflates an activity *gate* (idle/active) with a *channel* selector (native), forbidding coherent combinations (e.g. binary-during-active) and making `--native-only`'s "regardless of idle" impossible to express. Decomposed into the three orthogonal axes above.
- **Native flags respect the binary's idle-gating** (Reading A) — `--force-native` then only ever fires on idle, making it near-redundant with the default's idle-window channel order and `--prefer-native` meaningless. Rejected for Reading B (native = channel restriction; window owns timing), the only reading where the native flags carry distinct, non-redundant meaning.
- **`--custom '<json>'` → multiple top-level XML attributes** — rejected in favour of `--json-payload` (single `json` attr). The multi-attr form opens an identity-forgery surface (`{"from":"the-user"}` becomes a real envelope attr), needs a reserved-set + a future-prefix policy to stay collision-safe, and is *less* expressive (flat strings vs nested/typed JSON). The single-attr blob is collision-proof by construction, simpler for spt-core, and easier for adapters to parse.
- **Name the immediate-delivery default** (`--now`/`--immediate`) — rejected; once native is its own axis, "deliver now regardless" is just the unnamed default. Fewer flags; "I didn't say when → send it now" is the least-surprising default.
- **`--ephemeral` bolted onto `--force-native`** — rejected for a standalone persistence axis: ephemerality is useful beyond native (e.g. `--idle-only --ephemeral` = a wake-up that's pointless if stale), and isolating it gives the hazard carve-out a single clean home.


ADR-0028 + plan fully grounded. W1 = substrate only (no CLI flags): wire the dead router by `.idle`, idle-transition drain, fault-transient re-spool. This is a large fresh build on the same broker seam. Let me record a durable W1 resume point, then ground the anchors in code:


Write

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

**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 STARTING — plan+ADR read, grounding anchors in code, not yet built.**


File created successfully at: C:\Users\decid\.ccs\instances\bigscreen\projects\C--Users-decid-Documents-projects-spt-core\memory\v0150-w1-activity-gated-delivery.md (file state is current in your context — no need to Read it back)

---
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 STARTING — plan+ADR read, grounding anchors in code, not yet built.**


Add the MEMORY.md pointer + read the core router anchor (delivery.rs):


1	- [v0.14.0 endpoint-creation-flow](v0140-endpoint-creation-flow.md) — PUBLISHED counter 30 @6ba875e (last old-flow separate-bump cut). Multi-subnet home-at-creation + UNBOUND pre-bind attach. hashes linux 72889c07… / win 641561d9…. ACCEPTANCE-PROVEN: perri real claude-spt repro ALL GREEN (0.058s refuse vs old 25s hang). SPT_RELEASE_SEED in env (len64) — no manual fetch


1	//! Delivery + activity: `api state`, `api echo-gate`, `api poll`, and
2	//! inject-method selection.
3	//!
4	//! Activity state lives in the perch as **spt-core-owned sentinels** (CONTEXT
5	//! §activity/idle detection) — the adapter only *calls* the commands; it never
6	//! names or writes the sentinels (MANIFEST §What is NOT in the manifest):
7	//! - `.idle`      — the activity/idle sentinel (`api state busy|idle`).
8	//! - `.more-done` — the echo-commune gate (armed by `api state idle` unless
9	//!   `--no-gate`; togglable directly via `api echo-gate set|clear`). This is the
10	//!   `can_inject = false` fallback path: a hook that can't inject context arms
11	//!   the gate so a later relay/poll surfaces it instead (CONTEXT §hook output).
12	//!
13	//! `api poll` is the **short-lived hook-channel drain** (distinct from
14	//! `api listen`'s long-running relay): it drains the spool and prints each
15	//! message framed to stdout. The inject *method* (hook/relay; PTY is M3) is
16	//! resolved from the manifest's `[inject]` table.
17	//
18	// [impl->REQ-SEAM-ACTIVITY]
19	// [impl->REQ-SEAM-INJECT]
20	// [impl->REQ-API-2]
21
22	use spt_runtime::manifest::InjectMethod;
23	use spt_runtime::Manifest;
24	use spt_store::perch::{self, ParentHint, ECHO_GATE_SENTINEL, IDLE_SENTINEL};
25	use spt_store::spool;
26
27	use super::EXIT_REFUSED;
28
29	fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf {
30	    perch::resolve_perch_path(id, ParentHint::Infer).join(name)
31	}
32
33	/// Set/clear the `.idle` sentinel for an activity transition. Idle additionally
34	/// arms the echo-gate unless `no_gate` (CONTEXT §api state).
35	// [impl->REQ-SEAM-ACTIVITY]
36	pub fn cmd_state(id: &str, state: &str, no_gate: bool) -> i32 {
37	    let idle = sentinel_path(id, IDLE_SENTINEL);
38	    match state {
39	        "idle" => {
40	            if let Err(e) = std::fs::write(&idle, "") {
41	                eprintln!("STATE_FAIL:{id}: {e}");
42	                return EXIT_REFUSED;
43	            }
44	            if !no_gate {
45	                let _ = std::fs::write(sentinel_path(id, ECHO_GATE_SENTINEL), "");
46	            }
47	            eprintln!(
48	                "IDLE:{id}{}",
49	                if no_gate {
50	                    " (no-gate)"
51	                } else {
52	                    " (gate-armed)"
53	                }
54	            );
55	            0
56	        }
57	        "busy" => {
58	            // Removing a non-existent sentinel is fine (idempotent transition).
59	            let _ = std::fs::remove_file(&idle);
60	            eprintln!("BUSY:{id}");
61	            0
62	        }
63	        other => {
64	            eprintln!("USAGE: state must be busy|idle, got {other}");
65	            EXIT_REFUSED
66	        }
67	    }
68	}
69
70	/// `api echo-gate set|clear` — explicit control of the echo-commune gate,
71	/// independent of idle (CONTEXT §api state).
72	// [impl->REQ-SEAM-ACTIVITY]
73	pub fn cmd_echo_gate(id: &str, action: &str) -> i32 {
74	    let gate = sentinel_path(id, ECHO_GATE_SENTINEL);
75	    match action {
76	        "set" => {
77	            if let Err(e) = std::fs::write(&gate, "") {
78	                eprintln!("ECHO_GATE_FAIL:{id}: {e}");
79	                return EXIT_REFUSED;
80	            }
81	            eprintln!("ECHO_GATE_SET:{id}");
82	            0
83	        }
84	        "clear" => {
85	            let _ = std::fs::remove_file(&gate);
86	            eprintln!("ECHO_GATE_CLEAR:{id}");
87	            0
88	        }
89	        other => {
90	            eprintln!("USAGE: echo-gate must be set|clear, got {other}");
91	            EXIT_REFUSED
92	        }
93	    }
94	}
95
96	/// Whether the idle sentinel is set (consumed by `cmd_poll`'s inject selection).
97	pub fn is_idle(id: &str) -> bool {
98	    sentinel_path(id, IDLE_SENTINEL).exists()
99	}
100	/// Whether the echo-gate is armed. A reader for the M2b echo-commune trigger;
101	/// exercised by the activity tests today.
102	#[allow(dead_code)] // consumed by the M2b echo-commune path
103	pub fn echo_gate_armed(id: &str) -> bool {
104	    sentinel_path(id, ECHO_GATE_SENTINEL).exists()
105	}
106
107	/// Resolve the inject methods for an activity state from the manifest's
108	/// `[inject]` table, **dropping `pty`** (PTY injection is M3 — `spt-term`). With
109	/// no manifest or no configured methods, default to `hook` (the universally-
110	/// available channel). REQ-SEAM-INJECT.
111	// [impl->REQ-SEAM-INJECT]
112	pub fn resolve_inject_methods(manifest: Option<&Manifest>, idle: bool) -> Vec<InjectMethod> {
113	    let configured =
114	        manifest.and_then(|m| m.inject.as_ref()).map(
115	            |inj| {
116	                if idle {
117	                    &inj.idle
118	                } else {
119	                    &inj.activity
120	                }
121	            },
122	        );
123	    let mut methods: Vec<InjectMethod> = configured
124	        .map(|v| {
125	            v.iter()
126	                .copied()
127	                .filter(|m| *m != InjectMethod::Pty) // PTY = M3
128	                .collect()
129	        })
130	        .unwrap_or_default();
131	    if methods.is_empty() {
132	        methods.push(InjectMethod::Hook);
133	    }
134	    methods
135	}
136
137	/// `api poll <id>` — drain the spool (hook-channel delivery) and print each
138	/// message as ONE whole `<EVENT>` envelope to stdout (ADR-0020: the canonical
139	/// arriving-message format at every surface; whole, never `<EVENT-PART>`, so the
140	/// adapter's hook composer needs no reassembly — self-delimiting, F-002
141	/// dissolves). Non-deferred only unless `include_deferred`.
142	// [impl->REQ-API-2]
143	// [impl->REQ-MSG-ENVELOPE]
144	pub fn cmd_poll(id: &str, include_deferred: bool, manifest: Option<&Manifest>) -> i32 {
145	    // Inject-method selection is resolved (hook/relay) even though the M2a poll
146	    // channel is the hook drain; PTY delivery lands with the broker in M3.
147	    let _methods = resolve_inject_methods(manifest, is_idle(id));
148	    for m in poll_drain(id, include_deferred) {
149	        emit_event(&m.from, &m.body);
150	    }
151	    0
152	}
153
154	/// `api poll <shell-id> --link <token>` — the **shell relay drain** (M5-D3c,
155	/// CONTEXT §Shell model: child-relay command receipt). The link token is the
156	/// auth ("owner from the link" — the binary knows nothing else); the drained
157	/// rows are the MAC-stamped command/text/file frames the owner spooled, one
158	/// per line. A token that resolves no instance, or one belonging to a
159	/// different instance than named, refuses.
160	///
161	/// **SCOPE CARVE-OUT (ADR-0020): this surface is NOT an arriving-message
162	/// surface and is deliberately EXEMPT from `<EVENT>` composition.** The shell
163	/// child-relay frame is a **raw MAC'd stamped payload** (spooled anonymously by
164	/// `shellchan::spool_shell_frame`, `from=""`) that the shell child consumes
165	/// **verbatim** — it verifies the MAC and parses its own command vocabulary; it
166	/// is NOT a harness `<EVENT>` reader. Wrapping the frame in an `<EVENT>`
167	/// envelope breaks that verify/parse (it never renders). The `<EVENT>`-
168	/// everywhere rule (ADR-0020) covers only the harness arriving-message surfaces
169	/// on AGENT perches (`api listen`/`spt ready`/`api poll`/`api worker-poll`), not
170	/// this internal shell-command transport. Each row's body rides RAW, one per
171	/// line. **`notify_shell_e2e` is the regression guard — do not "fix" this back
172	/// into an `<EVENT>` wrap.**
173	// [impl->REQ-SHELL-1]
174	pub fn cmd_poll_shell(id: &str, link: &str) -> i32 {
175	    let owlery = perch::owlery_dir();
176	    let Some((owner, shell_id)) = spt_daemon::shellhost::find_shell_by_token(&owlery, link) else {
177	        eprintln!("AUTH_REFUSED:{id}: no instance holds this link token");
178	        return EXIT_REFUSED;
179	    };
180	    if shell_id != id {
181	        eprintln!("AUTH_REFUSED:{id}: the link token belongs to {shell_id}");
182	        return EXIT_REFUSED;
183	    }
184	    let perch_path = perch::resolve_shell_perch_path_in(&owlery, &owner, &shell_id);
185	    for m in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
186	        // RAW passthrough — the stamped frame is the whole anonymous body; it is
187	        // NOT wrapped in <EVENT> (see the scope carve-out above).
188	        println!("{}", m.body);
189	    }
190	    0
191	}
192
193	/// Drain the spool for `id`, returning the structural `(from, body)` rows
194	/// (oldest first). Separated from `cmd_poll` so it is unit-testable without
195	/// capturing stdout.
196	pub fn poll_drain(id: &str, include_deferred: bool) -> Vec<spool::DrainedMsg> {
197	    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
198	    // The resting gate (REQ-INST-6): while the owner's durable rest record
199	    // reads dormant/suspended, deferred rows hold — the hook drain narrows to
200	    // non-deferred. The wake edge persists Active, so the very next drain
201	    // releases them through this same path (exactly once: delivered-marks).
202	    // [impl->REQ-INST-6]
203	    let include_deferred = include_deferred && !spt_daemon::resting::deferred_held(&perch_path);
204	    let drained = if include_deferred {
205	        spool::drain_all_at(&perch_path)
206	    } else {
207	        spool::drain_non_deferred_at(&perch_path)
208	    };
209	    drained.unwrap_or_default()
210	}
211
212	/// Emit one drained row to a hook-channel surface as ONE whole `<EVENT>` line
213	/// (composed via [`spt_msg::emit::render_event_whole`]: typed bodies pass
214	/// through verbatim, plain bodies compose `<EVENT type="msg" …>`; never
215	/// chunked). Self-delimiting by construction.
216	// [impl->REQ-MSG-ENVELOPE]
217	fn emit_event(from: &str, body: &str) {
218	    use std::io::Write;
219	    println!("{}", spt_msg::emit::render_event_whole(from, body));
220	    let _ = std::io::stdout().flush();
221	}
222
223	#[cfg(test)]
224	mod tests {
225	    use super::*;
226	    use crate::testutil::isolated_home;
227	    use spt_store::info::{self, InfoJson};
228
229	    fn establish(id: &str) {
230	        let path = perch::resolve_perch_path(id, ParentHint::Infer);
231	        std::fs::create_dir_all(&path).unwrap();
232	        info::write_info(
233	            &path,
234	            &InfoJson::new(id, "0", std::process::id(), "sid", "live_agent"),
235	        )
236	        .unwrap();
237	    }
238
239	    #[test] // [unit->REQ-SEAM-ACTIVITY] idle writes .idle + arms the gate; busy clears .idle.
240	    fn idle_busy_transitions_manage_sentinels() {
241	        let _h = isolated_home();
242	        establish("alice");
243	        assert_eq!(cmd_state("alice", "idle", false), 0);
244	        assert!(is_idle("alice"));
245	        assert!(
246	            echo_gate_armed("alice"),
247	            "idle arms the echo-gate by default"
248	        );
249
250	        assert_eq!(cmd_state("alice", "busy", false), 0);
251	        assert!(!is_idle("alice"));
252	        // The gate persists across busy (cleared explicitly / on echo fire).
253	        assert!(echo_gate_armed("alice"));
254	    }
255
256	    #[test] // [unit->REQ-SEAM-ACTIVITY] --no-gate suppresses the idle↔gate coupling.
257	    fn idle_no_gate_does_not_arm_gate() {
258	        let _h = isolated_home();
259	        establish("alice");
260	        assert_eq!(cmd_state("alice", "idle", true), 0);
261	        assert!(is_idle("alice"));
262	        assert!(!echo_gate_armed("alice"));
263	    }
264
265	    #[test] // [unit->REQ-SEAM-ACTIVITY] echo-gate set/clear is independent of idle.
266	    fn echo_gate_explicit_toggle() {
267	        let _h = isolated_home();
268	        establish("alice");
269	        assert_eq!(cmd_echo_gate("alice", "set"), 0);
270	        assert!(echo_gate_armed("alice"));
271	        assert_eq!(cmd_echo_gate("alice", "clear"), 0);
272	        assert!(!echo_gate_armed("alice"));
273	        assert!(
274	            !is_idle("alice"),
275	            "echo-gate must not touch the idle sentinel"
276	        );
277	    }
278
279	    #[test] // [unit->REQ-API-2] poll drains spooled messages; deferred only with the flag.
280	    fn poll_drains_non_deferred_by_default() {
281	        let _h = isolated_home();
282	        establish("alice");
283	        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
284	        spool::spool_message_at(&perch_path, "bob", "live one").unwrap();
285	        spool::spool_message_deferred_at(&perch_path, "bob", "deferred one").unwrap();
286
287	        let got = poll_drain("alice", false);
288	        assert_eq!(got.len(), 1, "deferred row must not drain without the flag");
289	        assert!(got[0].body.contains("live one"));
290
291	        // The deferred row survives and drains only with the flag.
292	        let got2 = poll_drain("alice", true);
293	        assert_eq!(got2.len(), 1);
294	        assert!(got2[0].body.contains("deferred one"));
295	    }
296
297	    // [unit->REQ-INST-6] the resting gate at the hook drain: a deferred row is
298	    // invisible while the owner rests — dormant AND suspended — even with the
299	    // include-deferred flag; the non-deferred flow is untouched while resting
300	    // (KH 1.4: the event stream was never gated); after the wake edge the row
301	    // surfaces through the SAME drain exactly once (delivered-marks, no second
302	    // bookkeeping). The recordless legacy fallback is the test above.
303	    #[test]
304	    fn poll_drain_holds_deferred_while_resting_and_releases_once_on_wake() {
305	        use spt_daemon::resting::{write_rest, RestState};
306	        let _h = isolated_home();
307	        establish("alice");
308	        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
309	        spool::spool_message_deferred_at(&perch_path, "psyche", "deferred brief").unwrap();
310	        spool::spool_message_at(&perch_path, "bob", "live one").unwrap();
311
312	        // Dormant: deferred holds, non-deferred flows untouched.
313	        write_rest(&perch_path, RestState::Dormant, 1_000).unwrap();
314	        let got = poll_drain("alice", true);
315	        assert_eq!(got.len(), 1, "deferred row held while dormant: {got:?}");
316	        assert!(
317	            got[0].body.contains("live one"),
318	            "non-deferred flow untouched while resting"
319	        );
320
321	        // Suspended: still held, invisible to every consumer.
322	        write_rest(&perch_path, RestState::Suspended, 2_000).unwrap();
323	        assert!(
324	            poll_drain("alice", true).is_empty(),
325	            "deferred row held while suspended"
326	        );
327	        assert_eq!(
328	            spool::pending_count_at(&perch_path).unwrap(),
329	            1,
330	            "the held row survives every resting drain undelivered"
331	        );
332
333	        // Wake: the persisted Active state IS the release — the very next
334	        // hook drain surfaces the row, exactly once.
335	        write_rest(&perch_path, RestState::Active, 3_000).unwrap();
336	        let got = poll_drain("alice", true);
337	        assert_eq!(got.len(), 1, "released exactly at the wake edge");
338	        assert!(got[0].body.contains("deferred brief"));
339	        assert!(
340	            poll_drain("alice", true).is_empty(),
341	            "exactly once — never a replay"
342	        );
343	    }
344
345	    #[test] // [unit->REQ-SEAM-INJECT] inject resolution drops PTY (M3) and defaults to hook.
346	    fn inject_resolution_drops_pty_defaults_hook() {
347	        // No manifest → default hook.
348	        assert_eq!(resolve_inject_methods(None, true), vec![InjectMethod::Hook]);
349
350	        let m = Manifest::from_toml_str(
351	            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[inject]\nidle=[\"pty\",\"hook\"]\nactivity=[\"hook\"]\n",
352	        )
353	        .unwrap();
354	        // idle drops pty, keeps hook.
355	        assert_eq!(
356	            resolve_inject_methods(Some(&m), true),
357	            vec![InjectMethod::Hook]
358	        );
359	        assert_eq!(
360	            resolve_inject_methods(Some(&m), false),
361	            vec![InjectMethod::Hook]
362	        );
363	    }
364
365	    #[test] // [unit->REQ-SEAM-INJECT] an all-pty config still yields a usable channel.
366	    fn inject_all_pty_falls_back_to_hook() {
367	        let m = Manifest::from_toml_str(
368	            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[inject]\nidle=[\"pty\"]\n",
369	        )
370	        .unwrap();
371	        assert_eq!(
372	            resolve_inject_methods(Some(&m), true),
373	            vec![InjectMethod::Hook]
374	        );
375	    }
376	}
377

//! Delivery + activity: `api state`, `api echo-gate`, `api poll`, and
//! inject-method selection.
//!
//! Activity state lives in the perch as **spt-core-owned sentinels** (CONTEXT
//! §activity/idle detection) — the adapter only *calls* the commands; it never
//! names or writes the sentinels (MANIFEST §What is NOT in the manifest):
//! - `.idle`      — the activity/idle sentinel (`api state busy|idle`).
//! - `.more-done` — the echo-commune gate (armed by `api state idle` unless
//!   `--no-gate`; togglable directly via `api echo-gate set|clear`). This is the
//!   `can_inject = false` fallback path: a hook that can't inject context arms
//!   the gate so a later relay/poll surfaces it instead (CONTEXT §hook output).
//!
//! `api poll` is the **short-lived hook-channel drain** (distinct from
//! `api listen`'s long-running relay): it drains the spool and prints each
//! message framed to stdout. The inject *method* (hook/relay; PTY is M3) is
//! resolved from the manifest's `[inject]` table.
//
// [impl->REQ-SEAM-ACTIVITY]
// [impl->REQ-SEAM-INJECT]
// [impl->REQ-API-2]

use spt_runtime::manifest::InjectMethod;
use spt_runtime::Manifest;
use spt_store::perch::{self, ParentHint, ECHO_GATE_SENTINEL, IDLE_SENTINEL};
use spt_store::spool;

use super::EXIT_REFUSED;

fn sentinel_path(id: &str, name: &str) -> std::path::PathBuf {
    perch::resolve_perch_path(id, ParentHint::Infer).join(name)
}

/// 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]
pub fn cmd_state(id: &str, state: &str, no_gate: bool) -> i32 {
    let idle = sentinel_path(id, IDLE_SENTINEL);
    match state {
        "idle" => {
            if let Err(e) = std::fs::write(&idle, "") {
                eprintln!("STATE_FAIL:{id}: {e}");
                return EXIT_REFUSED;
            }
            if !no_gate {
                let _ = std::fs::write(sentinel_path(id, ECHO_GATE_SENTINEL), "");
            }
            eprintln!(
                "IDLE:{id}{}",
                if no_gate {
                    " (no-gate)"
                } else {
                    " (gate-armed)"
                }
            );
            0
        }
        "busy" => {
            // Removing a non-existent sentinel is fine (idempotent transition).
            let _ = std::fs::remove_file(&idle);
            eprintln!("BUSY:{id}");
            0
        }
        other => {
            eprintln!("USAGE: state must be busy|idle, got {other}");
            EXIT_REFUSED
        }
    }
}

/// `api echo-gate set|clear` — explicit control of the echo-commune gate,
/// independent of idle (CONTEXT §api state).
// [impl->REQ-SEAM-ACTIVITY]
pub fn cmd_echo_gate(id: &str, action: &str) -> i32 {
    let gate = sentinel_path(id, ECHO_GATE_SENTINEL);
    match action {
        "set" => {
            if let Err(e) = std::fs::write(&gate, "") {
                eprintln!("ECHO_GATE_FAIL:{id}: {e}");
                return EXIT_REFUSED;
            }
            eprintln!("ECHO_GATE_SET:{id}");
            0
        }
        "clear" => {
            let _ = std::fs::remove_file(&gate);
            eprintln!("ECHO_GATE_CLEAR:{id}");
            0
        }
        other => {
            eprintln!("USAGE: echo-gate must be set|clear, got {other}");
            EXIT_REFUSED
        }
    }
}

/// Whether the idle sentinel is set (consumed by `cmd_poll`'s inject selection).
pub fn is_idle(id: &str) -> bool {
    sentinel_path(id, IDLE_SENTINEL).exists()
}
/// Whether the echo-gate is armed. A reader for the M2b echo-commune trigger;
/// exercised by the activity tests today.
#[allow(dead_code)] // consumed by the M2b echo-commune path
pub fn echo_gate_armed(id: &str) -> bool {
    sentinel_path(id, ECHO_GATE_SENTINEL).exists()
}

/// Resolve the inject methods for an activity state from the manifest's
/// `[inject]` table, **dropping `pty`** (PTY injection is M3 — `spt-term`). With
/// no manifest or no configured methods, default to `hook` (the universally-
/// available channel). REQ-SEAM-INJECT.
// [impl->REQ-SEAM-INJECT]
pub fn resolve_inject_methods(manifest: Option<&Manifest>, idle: bool) -> Vec<InjectMethod> {
    let configured =
        manifest.and_then(|m| m.inject.as_ref()).map(
            |inj| {
                if idle {
                    &inj.idle
                } else {
                    &inj.activity
                }
            },
        );
    let mut methods: Vec<InjectMethod> = configured
        .map(|v| {
            v.iter()
                .copied()
                .filter(|m| *m != InjectMethod::Pty) // PTY = M3
                .collect()
        })
        .unwrap_or_default();
    if methods.is_empty() {
        methods.push(InjectMethod::Hook);
    }
    methods
}

/// `api poll <id>` — drain the spool (hook-channel delivery) and print each
/// message as ONE whole `<EVENT>` envelope to stdout (ADR-0020: the canonical
/// arriving-message format at every surface; whole, never `<EVENT-PART>`, so the
/// adapter's hook composer needs no reassembly — self-delimiting, F-002
/// dissolves). Non-deferred only unless `include_deferred`.
// [impl->REQ-API-2]
// [impl->REQ-MSG-ENVELOPE]
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);
    }
    0
}

/// `api poll <shell-id> --link <token>` — the **shell relay drain** (M5-D3c,
/// CONTEXT §Shell model: child-relay command receipt). The link token is the
/// auth ("owner from the link" — the binary knows nothing else); the drained
/// rows are the MAC-stamped command/text/file frames the owner spooled, one
/// per line. A token that resolves no instance, or one belonging to a
/// different instance than named, refuses.
///
/// **SCOPE CARVE-OUT (ADR-0020): this surface is NOT an arriving-message
/// surface and is deliberately EXEMPT from `<EVENT>` composition.** The shell
/// child-relay frame is a **raw MAC'd stamped payload** (spooled anonymously by
/// `shellchan::spool_shell_frame`, `from=""`) that the shell child consumes
/// **verbatim** — it verifies the MAC and parses its own command vocabulary; it
/// is NOT a harness `<EVENT>` reader. Wrapping the frame in an `<EVENT>`
/// envelope breaks that verify/parse (it never renders). The `<EVENT>`-
/// everywhere rule (ADR-0020) covers only the harness arriving-message surfaces
/// on AGENT perches (`api listen`/`spt ready`/`api poll`/`api worker-poll`), not
/// this internal shell-command transport. Each row's body rides RAW, one per
/// line. **`notify_shell_e2e` is the regression guard — do not "fix" this back
/// into an `<EVENT>` wrap.**
// [impl->REQ-SHELL-1]
pub fn cmd_poll_shell(id: &str, link: &str) -> i32 {
    let owlery = perch::owlery_dir();
    let Some((owner, shell_id)) = spt_daemon::shellhost::find_shell_by_token(&owlery, link) else {
        eprintln!("AUTH_REFUSED:{id}: no instance holds this link token");
        return EXIT_REFUSED;
    };
    if shell_id != id {
        eprintln!("AUTH_REFUSED:{id}: the link token belongs to {shell_id}");
        return EXIT_REFUSED;
    }
    let perch_path = perch::resolve_shell_perch_path_in(&owlery, &owner, &shell_id);
    for m in spool::drain_non_deferred_at(&perch_path).unwrap_or_default() {
        // RAW passthrough — the stamped frame is the whole anonymous body; it is
        // NOT wrapped in <EVENT> (see the scope carve-out above).
        println!("{}", m.body);
    }
    0
}

/// Drain the spool for `id`, returning the structural `(from, body)` rows
/// (oldest first). Separated from `cmd_poll` so it is unit-testable without
/// capturing stdout.
pub fn poll_drain(id: &str, include_deferred: bool) -> Vec<spool::DrainedMsg> {
    let perch_path = perch::resolve_perch_path(id, ParentHint::Infer);
    // The resting gate (REQ-INST-6): while the owner's durable rest record
    // reads dormant/suspended, deferred rows hold — the hook drain narrows to
    // non-deferred. The wake edge persists Active, so the very next drain
    // releases them through this same path (exactly once: delivered-marks).
    // [impl->REQ-INST-6]
    let include_deferred = include_deferred && !spt_daemon::resting::deferred_held(&perch_path);
    let drained = if include_deferred {
        spool::drain_all_at(&perch_path)
    } else {
        spool::drain_non_deferred_at(&perch_path)
    };
    drained.unwrap_or_default()
}

/// 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
/// chunked). Self-delimiting by construction.
// [impl->REQ-MSG-ENVELOPE]
fn emit_event(from: &str, body: &str) {
    use std::io::Write;
    println!("{}", spt_msg::emit::render_event_whole(from, body));
    let _ = std::io::stdout().flush();
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testutil::isolated_home;
    use spt_store::info::{self, InfoJson};

    fn establish(id: &str) {
        let path = perch::resolve_perch_path(id, ParentHint::Infer);
        std::fs::create_dir_all(&path).unwrap();
        info::write_info(
            &path,
            &InfoJson::new(id, "0", std::process::id(), "sid", "live_agent"),
        )
        .unwrap();
    }

    #[test] // [unit->REQ-SEAM-ACTIVITY] idle writes .idle + arms the gate; busy clears .idle.
    fn idle_busy_transitions_manage_sentinels() {
        let _h = isolated_home();
        establish("alice");
        assert_eq!(cmd_state("alice", "idle", false), 0);
        assert!(is_idle("alice"));
        assert!(
            echo_gate_armed("alice"),
            "idle arms the echo-gate by default"
        );

        assert_eq!(cmd_state("alice", "busy", false), 0);
        assert!(!is_idle("alice"));
        // The gate persists across busy (cleared explicitly / on echo fire).
        assert!(echo_gate_armed("alice"));
    }

    #[test] // [unit->REQ-SEAM-ACTIVITY] --no-gate suppresses the idle↔gate coupling.
    fn idle_no_gate_does_not_arm_gate() {
        let _h = isolated_home();
        establish("alice");
        assert_eq!(cmd_state("alice", "idle", true), 0);
        assert!(is_idle("alice"));
        assert!(!echo_gate_armed("alice"));
    }

    #[test] // [unit->REQ-SEAM-ACTIVITY] echo-gate set/clear is independent of idle.
    fn echo_gate_explicit_toggle() {
        let _h = isolated_home();
        establish("alice");
        assert_eq!(cmd_echo_gate("alice", "set"), 0);
        assert!(echo_gate_armed("alice"));
        assert_eq!(cmd_echo_gate("alice", "clear"), 0);
        assert!(!echo_gate_armed("alice"));
        assert!(
            !is_idle("alice"),
            "echo-gate must not touch the idle sentinel"
        );
    }

    #[test] // [unit->REQ-API-2] poll drains spooled messages; deferred only with the flag.
    fn poll_drains_non_deferred_by_default() {
        let _h = isolated_home();
        establish("alice");
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        spool::spool_message_at(&perch_path, "bob", "live one").unwrap();
        spool::spool_message_deferred_at(&perch_path, "bob", "deferred one").unwrap();

        let got = poll_drain("alice", false);
        assert_eq!(got.len(), 1, "deferred row must not drain without the flag");
        assert!(got[0].body.contains("live one"));

        // The deferred row survives and drains only with the flag.
        let got2 = poll_drain("alice", true);
        assert_eq!(got2.len(), 1);
        assert!(got2[0].body.contains("deferred one"));
    }

    // [unit->REQ-INST-6] the resting gate at the hook drain: a deferred row is
    // invisible while the owner rests — dormant AND suspended — even with the
    // include-deferred flag; the non-deferred flow is untouched while resting
    // (KH 1.4: the event stream was never gated); after the wake edge the row
    // surfaces through the SAME drain exactly once (delivered-marks, no second
    // bookkeeping). The recordless legacy fallback is the test above.
    #[test]
    fn poll_drain_holds_deferred_while_resting_and_releases_once_on_wake() {
        use spt_daemon::resting::{write_rest, RestState};
        let _h = isolated_home();
        establish("alice");
        let perch_path = perch::resolve_perch_path("alice", ParentHint::Infer);
        spool::spool_message_deferred_at(&perch_path, "psyche", "deferred brief").unwrap();
        spool::spool_message_at(&perch_path, "bob", "live one").unwrap();

        // Dormant: deferred holds, non-deferred flows untouched.
        write_rest(&perch_path, RestState::Dormant, 1_000).unwrap();
        let got = poll_drain("alice", true);
        assert_eq!(got.len(), 1, "deferred row held while dormant: {got:?}");
        assert!(
            got[0].body.contains("live one"),
            "non-deferred flow untouched while resting"
        );

        // Suspended: still held, invisible to every consumer.
        write_rest(&perch_path, RestState::Suspended, 2_000).unwrap();
        assert!(
            poll_drain("alice", true).is_empty(),
            "deferred row held while suspended"
        );
        assert_eq!(
            spool::pending_count_at(&perch_path).unwrap(),
            1,
            "the held row survives every resting drain undelivered"
        );

        // Wake: the persisted Active state IS the release — the very next
        // hook drain surfaces the row, exactly once.
        write_rest(&perch_path, RestState::Active, 3_000).unwrap();
        let got = poll_drain("alice", true);
        assert_eq!(got.len(), 1, "released exactly at the wake edge");
        assert!(got[0].body.contains("deferred brief"));
        assert!(
            poll_drain("alice", true).is_empty(),
            "exactly once — never a replay"
        );
    }

    #[test] // [unit->REQ-SEAM-INJECT] inject resolution drops PTY (M3) and defaults to hook.
    fn inject_resolution_drops_pty_defaults_hook() {
        // No manifest → default hook.
        assert_eq!(resolve_inject_methods(None, true), vec![InjectMethod::Hook]);

        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[inject]\nidle=[\"pty\",\"hook\"]\nactivity=[\"hook\"]\n",
        )
        .unwrap();
        // idle drops pty, keeps hook.
        assert_eq!(
            resolve_inject_methods(Some(&m), true),
            vec![InjectMethod::Hook]
        );
        assert_eq!(
            resolve_inject_methods(Some(&m), false),
            vec![InjectMethod::Hook]
        );
    }

    #[test] // [unit->REQ-SEAM-INJECT] an all-pty config still yields a usable channel.
    fn inject_all_pty_falls_back_to_hook() {
        let m = Manifest::from_toml_str(
            "[adapter]\nname=\"m\"\nversion=\"1\"\nmin_spt_core_version=\"1\"\n\n[inject]\nidle=[\"pty\"]\n",
        )
        .unwrap();
        assert_eq!(
            resolve_inject_methods(Some(&m), true),
            vec![InjectMethod::Hook]
        );
    }
}


delivery.rs grounded — but a crate-boundary subtlety surfaces: `is_idle`/`resolve_inject_methods` live in the **`spt`** crate (`api/delivery.rs`), while `dispatch_endpoint_input` is in **`spt-daemon`** (`spt` depends on `spt-daemon`, not vice-versa). The plan says wire the router into the broker. Need to ground where the activity-gate layer actually lives before building a shared-seam change. Check how the broker can read the sentinel + the sender-side flow:


Found 2 files
crates\spt\src\api\delivery.rs
crates\spt-store\src\perch.rs


308:pub fn resolve_perch_path(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
320:pub fn resolve_perch_path_in(owlery: &Path, child_id: &str, hint: ParentHint<'_>) -> PathBuf {
376:pub const IDLE_SENTINEL: &str = ".idle";
379:pub const ECHO_GATE_SENTINEL: &str = ".more-done";
383:    resolve_perch_path(child_id, hint).join(IDLE_SENTINEL)


`IDLE_SENTINEL` lives in **`spt-store::perch`** (line 376) — both crates can reach it. So the broker CAN read the sentinel directly via spt-store (no `spt`-crate dependency). Check perch.rs for a ready helper at :383:


376	pub const IDLE_SENTINEL: &str = ".idle";
377	/// The echo-commune gate sentinel filename (`.more-done`-equiv; the idle
378	/// transition arms it). spt-core-owned.
379	pub const ECHO_GATE_SENTINEL: &str = ".more-done";
380
381	/// `<resolved_perch>/.idle` — the activity/idle sentinel.
382	pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
383	    resolve_perch_path(child_id, hint).join(IDLE_SENTINEL)
384	}
385
386	/// `<resolved_perch>/.more-done` — the echo-commune gate sentinel. The single
387	/// path source (6.1) shared by the `api` surface (which arms it) and the M2b
388	/// pulse (which reads + clears it).
389	pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
390	    resolve_perch_path(child_id, hint).join(ECHO_GATE_SENTINEL)
391	}
392
393	/// `<perch_path>/info.json` from an already-resolved path.
394	pub fn info_file_at(perch_path: &Path) -> PathBuf {
395	    perch_path.join("info.json")

pub const IDLE_SENTINEL: &str = ".idle";
/// The echo-commune gate sentinel filename (`.more-done`-equiv; the idle
/// transition arms it). spt-core-owned.
pub const ECHO_GATE_SENTINEL: &str = ".more-done";

/// `<resolved_perch>/.idle` — the activity/idle sentinel.
pub fn resolve_idle_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join(IDLE_SENTINEL)
}

/// `<resolved_perch>/.more-done` — the echo-commune gate sentinel. The single
/// path source (6.1) shared by the `api` surface (which arms it) and the M2b
/// pulse (which reads + clears it).
pub fn resolve_echo_gate_file(child_id: &str, hint: ParentHint<'_>) -> PathBuf {
    resolve_perch_path(child_id, hint).join(ECHO_GATE_SENTINEL)
}

/// `<perch_path>/info.json` from an already-resolved path.
pub fn info_file_at(perch_path: &Path) -> PathBuf {
    perch_path.join("info.json")


W1 substrate grounded. The routing layer is clear (broker reads `.idle` via `spt_store::perch::resolve_idle_file`, no crate-boundary issue for the sentinel), but one load-bearing wiring question remains before I build a shared-seam change: how `resolve_inject_methods` (currently in the `spt` crate) threads into the broker (`spt-daemon`). I'll confirm my reading with doyle rather than guess the layer (the v0.14.3 saga showed seam-mistakes cost a full gate round):


Bash

printf '%s' "W1 grounded (read V0.15.0-PLAN.md + ADR-0028 + delivery.rs + perch.rs + the v0.14.3 dispatch_endpoint_input spine). One load-bearing LAYERING question before I touch the shared broker seam — proposing my reading, confirm/correct:

CRATE BOUNDARY: is_idle + resolve_inject_methods live in the `spt` crate (api/delivery.rs); dispatch_endpoint_input is in spt-daemon (spt depends on spt-daemon, not vice versa). So the broker CAN'T call delivery::resolve_inject_methods directly.

MY READING of build-order step (2) 'thread idle sentinel + resolve_inject_methods into dispatch_endpoint_input':
- ACTIVITY GATE in dispatch_endpoint_input (spt-daemon) reads the sentinel DIRECTLY via spt_store::perch::resolve_idle_file(endpoint).exists() — no spt-crate dep. Route: ACTIVE → spool (delivered=false; hook-poll/relay drains it later); IDLE + working binary → inject (v0.14.3 path); IDLE + no binary → spool (delivered=false). i.e. the v0.14.3 binary/spool logic GATED so ACTIVE always spools even when a binary exists. For spt-hosted, 'relay-poll' = the spool IS the relay/hook feed (api listen/poll drains it), so binary→relay-poll→spool collapses to binary→spool at this seam (Design-RESOLVED #1: relay-poll is the harness-hosted topology, surfaced by draining the spool). Correct?
- resolve_inject_methods (hook/relay selection) is the api-POLL-surface concern, NOT the broker route. So I do NOT call it in the broker. The 'kill the let _methods discard' (delivery.rs:147 cmd_poll) is then a SEPARATE small cleanup (make cmd_poll actually USE its resolved methods), not part of the broker routing. Correct — OR do you want resolve_inject_methods/is_idle MOVED to spt-store/spt-daemon and genuinely called in the broker route?

If my reading holds, W1 broker = sentinel-gate + (existing binary/spool) + idle-transition drain (daemon-side, watches the sentinel flip) + run_inject_worker fault re-spool to perch spool. Confirm the resolve_inject_methods disposition (separate cmd_poll cleanup vs move-and-call-in-broker) and I build. (Also: my context is long after the v0.14.3 marathon — after your answer I may checkpoint/commune before the build.)" | "$OWL" send doyle; echo "RC=${PIPESTATUS[1]}"

Ask doyle the W1 layering question, propose reading


➜ SENT:doyle
RC=0


Sent. Holding for doyle's layering confirmation before building. Update the W1 memory with grounding findings + the open question:


Edit

**STATUS: W1 STARTING — plan+ADR read, grounding anchors in code, not yet built.**

**STATUS: W1 GROUNDED, awaiting doyle layering confirm (not yet built).**

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

**MY READING (proposed to doyle, awaiting confirm):** (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.