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