# W4 #350 SHELLS FOLLOW ACTIVE + SENDER-SIDE SPOOL — census + picks (todlando → doyle, 2026-09-27)

Branch `feat/351-w4-shells` off 91fe5f30, in worktree `.worktrees/351-w4`. It must rebase onto the W3 head (280df6da or later) before impl. No impl, no build, no pool claimed.
Model sources:
- CONTEXT.md:409 (shells follow active)
- :402-405 (wake resolution, amended)
- :303 / :830 (active, vacancy)
REQs: REQ-SHELLS-FOLLOW-ACTIVE (`traceable-reqs.toml:8123`) and REQ-SHELL-OUTBOUND-SPOOL (:8128), both `[]`.
Provenance: (R) means I read it myself at 91fe5f30. The rest comes from the census subagent, cited file:line in `.spt/preserved/350/census-raw.md`. My raw notes are in `census-raw-todlando.md`.

## Census facts that shape the picks

**There are three shell→owner paths today, and none of them follows active on its own terms:**
- **Text (R).** The binary runs an ordinary `spt send <owner> --from <shell-id>` (`frames.md:136-139`). That is an UNPROVEN label. Since W2, `pre_route` already sends a bare `<owner>` to the ACTIVE instance when one exists, pinning the WAN leg when it is remote. In a vacancy it falls to the warm tier (`resolve_among` :1146-1154) and lands on a dormant or suspended instance.
- **Sensory (R).** `api emit <shell-id> --type --link` (`reporting.rs:565`). The link token PROVES (owner, shell). It does a local `deliver_tcp(owner, "", frame)` or a drop. It never leaves the node and is never spooled.
- **File (R).** There is NO shell→owner file primitive. `shell send --file` is owner→shell only. The only upward file carrier is `spt send --attachment` (ADR-0058 pull model).

**The steal defect is live (R).** `message_trigger` (`resting.rs:267`) maps a non-empty sender other than the target to `RestEvent::Wake`. A shell's text send has from=`<shell-id>`, so it is a stealing message at the local admission (`cli.rs:13331`/:13691) and at WAN admission (`wan.rs:1363`). With an active instance present, the steal is a no-op, because pre_route already delivered to active. In a vacancy it wakes the dormant instance the resolver fell back to. That is exactly the "shell traffic steals active" the model forbids. Sensory is exempt today only by accident: its wire `from` is "" (`resting.rs:262`, "shell frames").

**No outbound spool exists.** Zero code hits. `spt_store::spool` is per-perch INBOUND. The shell perch's `spool.db` is the INBOUND owner→shell command/text/file queue. "The existing spool bounds" = TTL 0 (infinite) + the wire `MAX_MESSAGE_BYTES` 16 MiB (`spt-msg wire.rs:21`). spool.rs has no row cap.

**Cross-node carrier.**
- `WanMessage.body` is opaque, and a typed `<EVENT>` body rides verbatim through the wire, the spool and delivery (`wan.rs:1385`, the user-msg precedent). So a sensory frame can ride the WAN leg unchanged.
- The DAEMON can send a WanMessage itself: `Brain::cold_start` + `brain.net_dial` + `spt_daemon::request_wan`. That is the `forward_wake` precedent (`shellwake.rs:470`).
- `wan_send_with` lives in the `spt` crate (because of Exclusions and unlisted-evidence routing), so the daemon cannot call it. A drain pinned to a known active node needs neither of those.

**Activation visibility hooks.**
- `dispatch.rs:1407` `commit_feed_batch`: a remote activation arrives here. Today it only demotes an outranked local Active.
- `resting::daemon_rest_event` / `daemon_sibling_activated`: a local edge. Both already run `cascade_shells_on_edge`.
- `shellwake::reconcile_once`: a ~5 s tick.

**Wake-watcher: most of it is already W1.** `resolve_wake` (`shellwake.rs:364`, R) does nothing if the owner is active here, leaves it in place if it is active elsewhere, and otherwise sends `Wake` → active. That is REQ-ACTIVATION-TRIGGERS clause 5. Two gaps remain:
- (a) the no-local-owner branch forwards to `remote_owner_node`, which is a last_active_ms RECENCY pick, not active-first (:444);
- (b) its doc (:344-360, :383) still reads D4/D8c.

**"A linked shell counts as a driver" has no code left.** The daemon/cli/store greps for shell+driver return zero. Only docs could still carry the pre-2026-09-25 wording.

**Reverse direction (out of #350, flagged).** Owner→shell cross-node exists only as `SHELL_LINK_{RELINK,CMD,DRIVE}` (`shelllink.rs`). So an active owner on B can `cmd` or `drive` a shell on A, but it cannot `shell send` text or a file to it. W3's restriction refuses the DORMANT A instance's shell traffic, which leaves text/file to A's shells unreachable while B is active. See Q6.

## Picks

**(1) One shell-side send primitive, PROVEN by the link: `spt api shell-say <shell-id> --link <token>` (text on stdin, repeatable `--attachment`).**
- It mirrors `api emit`. The token resolves (owner, shell) through `find_shell_by_token`.
- `api emit` gets the same routing (below).
- `spt send --from <shell-id>` stays working for N-1 shell binaries. It is steered too: when `from` names a shell registered under the target owner on THIS node, the send takes the shell path. That keying is unproven, and it is harmless, because it can only SUPPRESS a steal (already forgeable with `--from <owner>`) and send toward active. It never grants a gate pass (see 4).
- The docs name `api shell-say` as the way; `spt send --from` becomes the compat path. **Q1.**

**(2) Routing = active only, never the warm fallback.**
- A new resolver entry, `active_owner(owner) -> Option<Local | Remote(node)>`, reads only the ACTIVE rung of `resolve_among` over the registry with the local view overlaid (`with_local_view`, so a just-woken local instance wins).
- Local → the existing local deliver (TCP, else the owner's inbound spool; sensory stays live-or-drop locally because the owner is active and warm, see 5). Remote → the WAN leg pinned `owner@node`.
- `None` (vacancy) → the outbound spool.
- **Order invariant:** if the outbound spool holds ANY row, a new payload appends behind it and nudges a drain. It never bypasses the queue, even when active has just appeared ("in send order").

**(3) No shell payload ever triggers an activation.**
- The shell path never calls `admit_message_trigger`.
- The receiver classifies shell traffic as non-stealing through a marker the WAN record carries: `WanMessage.shell: Option<String>` (the shell id; serde default, skip if None, the N-1-safe precedent). With it set, `message_trigger` returns None.
- An N-1 receiver ignores the field. Since we only ever ship to the instance that IS active, its steal is a no-op there.
- The local owner-perch deliver passes no sender to the trigger (the same as sensory's "" today).

**(4) Cross-node gate: shell traffic rides as its OWNER's sibling traffic.**
- A shell's node holds the owner's perch and mind. That is the R4-6 boundary the sibling bypass (`gate.rs:279`, REQ-SIBLING-BYPASS) already trusts.
- The daemon drain and the proven `shell-say`/`emit` path stamp `sender_proven = <owner>`, justified by the link token. B's gate passes it as Sibling.
- The unproven `--from <shell>` compat path does NOT get that stamp, so it faces B's ordinary posture. **Q2.**

**(5) Sensory keeps its channel semantics at the RECEIVER; only the vacancy spool is new.**
- Active local: live TCP or drop, as today.
- Active remote: WAN, where the receiver delivers live or spools like any typed envelope. It is active, so warm and online; a live-miss there is rare.
- Vacancy: spooled on the shell's node, per the ruling ("every kind, sensory included").
- Consequence, stated plainly: a spooled sensory frame drains STALE. The operator ruled that knowingly, and the frame's own timestamp lets a consumer tell. **Q3.**

**(6) The outbound spool = the existing spool module on its own file: `<shell-perch>/outbound/spool.db`.**
- `open_spool_at` on a subdir reuses the schema, TTL 0 and the 16 MiB cap byte-for-byte ("existing bounds, no new TTL"). It needs no migration and never mixes with the inbound owner→shell `spool.db`.
- A row stores the ready-to-ship body (the text msg, or the composed sensory `<EVENT>`) plus its kind in `channel`.
- Rows are marked delivered, never deleted (the module's audit posture).
- STORAGE.md gains the path.

**(7) Drain = daemon-side, oldest-first, stop at the first failure.**
- Triggers:
  - (a) `commit_feed_batch` when an applied claim shows an Active row for an owner that has shells with a non-empty outbound spool here;
  - (b) the local `daemon_rest_event` edge to Active (beside `cascade_shells_on_edge`);
  - (c) the `reconcile_once` tick as the backstop.
- Ship: local → deliver; remote → `Brain::cold_start` + `net_dial(addr_for_node_hex(node))` + `request_wan` with the pinned target and a fresh op id. `wan_seen` on the receiver dedups a retry after a lost reply (at-least-once on the wire, exactly-once at the perch).
- One drain per owner at a time: a per-owner in-process guard plus the row `taken_*` claim fields.
- The drain never waits for the shell to be online. A payload belongs to the owner once it is spooled.

**(8) Wake-watcher.**
- Keep W1's local arms.
- Fix `remote_owner_node` to be ACTIVE-first, falling back to recency only in a vacancy (the "no active, a dormant/suspended one reachable → wake it" arm).
- Rewrite the stale D4/D8c doc lines.
- After any wake that lands active, nudge the drain.

**(9) `presence::addressed_target`: OUT of #350.** It is notif first-fire routing (`notif.rs:335/339`), not shell traffic. Recommendation: its own small follow-up issue, "addressed notif → active instance, MRA only in a vacancy". The fix is one resolver rung, but a notif is a different surface with its own suppression and seen-mark semantics, so it should be ruled separately. **Q4.**

**(10) REQs + evidence.**
- Activate REQ-SHELLS-FOLLOW-ACTIVE and REQ-SHELL-OUTBOUND-SPOOL with doc/impl/unit/int.
- Amend REQ-SHELL-1's title: "sensory REST-only never spooled" becomes "sensory live-only at an active receiver; spooled on the shell's node only in a vacancy (REQ-SHELL-OUTBOUND-SPOOL)".
- Docs:
  - `shells/frames.md` (:136-139, :191-202)
  - `shells/overview.md`
  - `getting-started.md`
  - `harness-contract/api.md` (emit + the new shell-say)
  - `manifest.md` sensory comments
  - STORAGE.md
  - CONTEXT.md :206 and :347 (the sensory never-spooled phrasing)
- Units:
  - `active_owner` rungs (local active, remote active, dormant-only = None, local view overlay);
  - `message_trigger` with the shell marker ⇒ None, the same-id and different-id controls unchanged;
  - the order invariant (a non-empty spool forces append);
  - drain order + stop-at-first-failure + delivered marking;
  - the `remote_owner_node` active-first pick;
  - the compat `--from <shell>` recognition (registered here ⇒ shell path, a stranger id ⇒ ordinary send);
  - the stale unit `shell_channels_relay_sensory_and_text_file` (`cli.rs:36638`) re-pointed.
- **int (two-host, the acceptance):** twohost_axes cell 7:
  - 7a. The shell on A, owner active on B: a text and a sensory frame land at B. Rest state on A and B is byte-identical before and after (no steal).
  - 7b. Vacancy: owner suspended everywhere. Three payloads (text, sensory, text) spool on A; nothing reaches any perch; no instance wakes.
  - 7c. Then B wakes: the spool drains to B in order, and A stays dormant.
  - A local-only cell rides beside it: the vacancy drain to a local activation.
  - The CLI head stays UNPROVEN-LIVE like W2/W3 unless IR-161 has landed.

## Open questions

- **Q1 the primitive.** Should `api shell-say` be a new proven verb, or should the shell binary keep `spt send --from` and the path be recognized only by registry membership? I recommend the new verb plus compat recognition. Only a proven sender can earn the Sibling gate pass (Q2), and `api emit` is already the proven precedent. Cost: one new api verb, and adapters move over at their own pace.
- **Q2 the gate.** Should proven shell traffic cross as the owner's sibling traffic (`sender_proven=<owner>`, the R4-6 boundary)? I recommend YES. Otherwise a closed-posture B refuses its own active owner's shell, which makes "shells follow active" posture-dependent across the subnet. The unproven compat path gets no stamp.
- **Q3 sensory at the receiver + staleness.** Is it confirmed that (i) a remote active receiver may spool a sensory frame on a live-miss, like any typed envelope, and (ii) vacancy-spooled sensory drains even when stale, with no drop-on-drain filter? I recommend yes to both, as ruled: "every kind, no new TTL". A staleness filter would be a new TTL by another name.
- **Q4 addressed_target.** Is it a separate follow-up issue (my recommendation), or should it ride W4 as "route to active" for addressed notifs?
- **Q5 drain placement.** A daemon-side drain with a pinned node through `request_wan` (the forward_wake precedent), rather than moving `wan_send_with` out of the `spt` crate? I recommend daemon-side. The drain is event-driven by activation visibility, which only the daemon sees. Unlisted-evidence routing is irrelevant when the active row names the node.
- **Q6 reverse direction.** Owner→shell TEXT/FILE across nodes does not exist; only relink/cmd/drive do. With W3's restriction, a shell on A whose owner is active on B can talk up but cannot be texted down. Out of #350 (the request names shell→owner only). Should I file it as its own request, or is cmd/drive enough for now?
