# W3 #349 HANDOFF + DORMANT SEND RESTRICTION — census + picks (todlando → doyle, 2026-09-26)

Branch `feat/351-w3-handoff` off a2ba486b, worktree `.worktrees/351-w3`. No impl, no build, no pool claimed.
Model: CONTEXT.md:832-835 (already written), :822 (handoff trigger), REQ-INSTANCE-HANDOFF / REQ-DORMANT-SEND-RESTRICTION (`traceable-reqs.toml:8113/8118`, both `[]`).
Provenance: (R) = I read it myself at a2ba486b; the rest came from a census subagent, cited file:line.

## Census facts that shape the picks

**Nothing exists yet.** Zero `SEND_REFUSED`, zero instance-`handoff` code in `crates/` (every `handoff` hit is brain/broker/PTY handoff). There is no sender-side gate of any kind on instance state.

**The send path (R)** `cmd_send_verdict` (`cli.rs:13191`):
1. `from` is resolved.
2. `session_proven = roster::detect_self_id()` (:13240).
3. `pre_route` runs (:13257), skipped when busy-only or force-native. R4-9 keeps a bare own-id send Local (`wansend.rs:431`).
4. The local admission gate (:13283).
5. …the activation trigger `admit_message_trigger` (:13477), skipped when the send is pinned remote.
6. The delivery legs.

Shortform dispatch (`api/delivery.rs:221`) calls the same `cmd_send_verdict`. `spt ring` (`cli.rs:13857`) and the owner→shell verbs (`ShellCmd::Send/Cmd/Drive/Tunnel`, :1798; handler :26196) do NOT go through it.

**The sender can know its own state in-process (R).** `effective_rest_of(perch_path) -> (RestState, activation)` (`resting.rs:570`) reads info.json + liveness from disk, the same way `spt endpoint wake` does. It needs no IPC. `sibling_view(id)` (:600) returns `{active_sibling, max_activation}` from the registry snapshots, but not WHICH node. The W2 resolver's active rung names the node.

**The W1 machinery already does most of the swap (R):**
- `transition` (`resting.rs:171`):
  - Dormant|Suspended + `Wake` → Active.
  - Suspended + `SiblingMessage` → Dormant.
  - Dormant + `SiblingMessage` → no edge.
  - Active + `SiblingActivated` → Dormant.
- `message_trigger(target, sender, window)` (:267): a same bare id gives `SiblingMessage`, a different id gives `Wake`.
- The WAN admission point (`wan.rs:1311`, R) runs the classifier after the gate, the perch check and the replay check. It runs before delivery, so a suspended target is covered.
- The demotion of the old active needs no new code. The new active's advertisement (`request_advertise_now`) reaches the old active's feed apply (`dispatch.rs:1431`), where `sibling_outranks` → `daemon_sibling_activated` rests it dormant, and the rank is re-checked inside the host.
- Wake on a suspended instance writes intent Active. The daemon reconcile then resumes it ASYNC (ADR-0033, `cli.rs:~8320`), the same as `spt endpoint wake`.

**Condition (3) of #349 is ALREADY TRUE from W1.** An active instance's normal send to a sibling is a same-id message, which gives `SiblingMessage`: no edge from dormant, and suspended wakes Dormant. It needs only a pinning unit and int arm, no impl.

**Wire:**
- `WanMessage` (`spt-net/src/net/wanmsg.rs:104`) has serde-default optional fields. `sender_proven` and `sender_origin` are the precedent. There is no `deny_unknown_fields`.
- `WanReply.outcome` is a free string; the tokens today are delivered, spooled, duplicate, refused and no_perch. An N-1 receiver ignores an unknown field.

**Trust:** a handoff flag grants no new authority. A DIFFERENT-id message already steals active at the receiver. The flag only changes what a SAME-id message does, and only a sibling (or someone forging the sibling's `from`) sends same-id. The forge case can already suppress a steal today (`wan.rs:1302` comment).

## Picks

**(1) The dormant restriction is a sender-side CLI gate keyed on the SESSION-PROVEN self id.**
- Gate: `session_proven` is Some, AND that perch's `effective_rest_of` is Dormant ⇒ restrict.
- No proven id (an operator's bare CLI, `cli@node`) ⇒ no restriction. An operator is not an instance.
- It never keys on `--from`. A forged label must not be able to lock out, or unlock, anything (KH 7.5).
- Placement: in `cmd_send_verdict`, right after `pre_route` (so it knows where the target resolved) and before the admission gate. Nothing is spooled on refusal.
- Refusal text: `SEND_REFUSED_DORMANT:<target> — <id> is dormant here; its active instance is <id>@<node>. Message it there, or have it send you --handoff.` Exit 1, `SendVerdict::Refused{1}`.

**(2) What a dormant instance MAY send:**
- A send that resolves to the ACTIVE sibling: a pinned `Remote{id == own id, node}` whose row is Active. In practice that is `ling@<active node>`, because R4-9 keeps a bare `ling` local.
- A bare SELF-send (target == own id, staying local; the R4-9 self-send). This exemption is load-bearing: the recharge wake is a force-native self-send from the hook (the F1 finding), and refusing it would stop every dormant instance recharging. Treat it as not "a peer message". **Q1 below.**
- Everything else is refused, including:
  - `id@<dormant sibling node>`
  - `id@<suspended sibling node>`
  - any other endpoint
  - busy-only peer sends
  - force-native peer sends

**(3) Surfaces the gate covers:** `spt send` (and shortform through it), `spt ring`, and the owner→shell verbs `shell send/cmd/drive/tunnel` ("traffic to its own shells").
- One helper, `dormant_send_refusal(self_id, resolved) -> Option<String>`, called at each of the three heads.
- NOT covered: `rc` attach (not a message; attaching is the user-input way out), rest verbs, knock, fork, span, digest, notify. **Q2.**

**(4) `--handoff` is a clap flag on `Send`.**
- It conflicts with `--busy-only` and `--force-native` (the wire carries no window class, and force-native never leaves this node's broker). It rides in `SendExtras` (the send path is at its too-many-args ceiling).
- Sender preconditions, all checked BEFORE any byte moves. A failure prints `HANDOFF_REFUSED:<target> — <why>` and exits 1, and the sender stays active by construction because nothing was sent:
  - a. session_proven is Some and its local effective state is ACTIVE ("only the active instance may send it");
  - b. the target is the sender's OWN id, qualified `id@node` with node ≠ this node. A handoff to another endpoint is refused. A bare own id is refused with the sibling list (R4-9 keeps it local, so it could only hand off to itself). **Q3.**
  - c. the target row in the registry is Dormant or Suspended. Offline, or no row, is refused ("active never lands on a seat that cannot take it").
- No pre-route and no local leg: a sibling is by definition on another node, so this is always the pinned WAN leg.

**(5) The swap is RECEIVER-applied and CONVERGES THROUGH THE EXISTING PUSH. The sender never demotes itself.**
- The wire gets `WanMessage.handoff: bool` (serde default false, skip if false).
- At WAN admission (`wan.rs:1311`), `message_trigger` gains the flag: a same-id message with handoff ⇒ `RestEvent::Wake` (Dormant → Active; Suspended → Active + reconcile resume). A different-id message ignores the flag (it steals anyway).
- The new active takes `next_activation` > the sender's. Its advertisement demotes the sender through `sibling_outranks` → `SiblingActivated`, the same W1 path as any steal.
- Consequence, stated plainly: the sender goes dormant only once the sibling IS active. A suspended sibling whose resume fails never advertises active, so the sender stays active, which is the offline guarantee extended to "resume failed". The cost is a window of one push (≈ the W2 cell-2 RTT, 315 ms on the rig; plus the resume time for a suspended sibling) in which both read active. The Q1 of #345 (max activation_rank) already resolves that split to the new one.
- Rejected alternative: the sender demotes itself on a delivered reply. That is two writers of one transition, and it leaves active vacant if the receiver's Wake failed after the reply.

**(6) Confirmation is carried by the reply, not by polling.**
- A receiver that applied the handoff Wake replies with the outcome token `handoff`. The sender prints `HANDOFF: <id>@<node> takes active; this instance goes dormant when its activation arrives.`
- A plain `delivered`/`spooled` from a handoff-flagged send means an N-1 receiver ignored the flag. The sender prints `HANDOFF_NOT_TAKEN:<target> — delivered as a normal message; this instance stays active` and exits 1. The body WAS delivered; the exit code reports that the handoff did not happen.
- No new sender is exposed to the new token: only a handoff sender can receive it.
- `WAN_UNCONFIRMED` / `WAN_PEER_SILENT` / `WAN_REFUSED` / `WAN_NO_PERCH` keep their text, prefixed by `HANDOFF_REFUSED`, and the sender is still active (no receiver Wake was confirmed). **Q4** covers the one gap: a receiver that woke but whose reply was lost.

**(7) addressed_target (condition 6 of W2):** census confirms it is notif-only. Its callers are `notif.rs:335/339` inside notif first-fire, and it is not a send. Pick: out of #349. Refer it to the #350 census, where shells-follow-active also routes to the active instance. It is the same "route to active" question on a different surface.

**(8) REQs + evidence:**
- Activate REQ-INSTANCE-HANDOFF with doc/impl/unit/int, and REQ-DORMANT-SEND-RESTRICTION with doc/impl/unit/int.
- REQ-ACTIVATION-TRIGGERS item (3) gets its `[impl->]` at the classifier's handoff arm.
- Docs:
  - docs-site/src/instances/overview.md (new §handoff + the restriction);
  - messaging/overview.md failure table (SEND_REFUSED_DORMANT, HANDOFF_REFUSED, HANDOFF_NOT_TAKEN rows);
  - CONTEXT.md:832 (add the self-send exemption + the receiver-applied swap once ruled).
- Units:
  - classifier: handoff × same/different id × window;
  - transition via Wake from dormant and from suspended;
  - `dormant_send_refusal` across every target shape (self bare, own@active, own@dormant, own@suspended, peer, no proven id, active sender);
  - handoff preconditions (not active, bare own id, other id, offline row, no row);
  - reply token mapping including the N-1 `delivered`;
  - the W1 "active's normal message: no transition" pin.
- **int:** twohost_axes cell 6 at library level:
  1. A active, B dormant.
  2. B's restriction helper refuses a peer target and allows `X@A`.
  3. `wan_send_with(handoff)` A→B.
  4. B converges active, A converges dormant.
  5. Then the restriction helper on A refuses.
  
  Cell 6b: A active, B suspended, handoff ⇒ B active. That needs the rig's resume leg; if the rig has none, the arm asserts B's intent Active + activation > A's, and A stays active until B advertises. Say which you want.
  
  The CLI call site stays UNPROVEN-LIVE, like W2's, until IR-161 (hertz, core PR #285: seed-control stub + the home-canonical broker socket bind). If IR-161 lands before this lane gates, I ride it for the CLI arm.

## Open questions

- **Q1 dormant self-send.** Exempt a bare self-send (R4-9) from the restriction? I recommend YES: the recharge wake is one, and it is not "a peer message". The alternative (refuse) breaks recharge on every dormant instance. Also confirm: an ADAPTER's own self-directed traffic (pacer nudges come FROM PACER-0 TO the instance, so they are not affected) needs nothing further.
- **Q2 surface list.** Is send + ring + shell send/cmd/drive/tunnel the right set for "peer messages and traffic to its own shells"? Specifically: should `knock` (an access request, peer-facing) and `fork` be refused while dormant? I lean NO: neither is a message, and the model names messages and shell traffic only.
- **Q3 bare `--handoff ling`.** Refuse with the sibling list, or resolve it to the SOLE other live sibling when there is exactly one? I recommend REFUSE. A handoff moves attention, and the qualified form costs one word. It mirrors the "explicit override is always available" posture.
- **Q4 lost reply.** The receiver applied Wake but the reply was lost (`WAN_PEER_SILENT`). The sender prints HANDOFF_REFUSED and says it stays active, but the push will demote it anyway, and correctly (the sibling IS active). Is the fix to word it `HANDOFF_UNCONFIRMED — the sibling may have taken active; check spt endpoint list`, exit 1? I recommend that wording. The message must not promise "stays active" when it cannot know.
- **Q5 active vacancy.** A dormant instance whose active sibling is suspended (vacancy) has no active sibling to message. Keep the restriction (refusal names `spt endpoint wake <id>@<this node>`, or user input, as the way out) or lift it during vacancy? The model's text says restrict. I recommend RESTRICT with the wake way out. Lifting it would make a dormant seat a silent second voice exactly when nobody holds attention.
