# M9 — Cross-node Gateway WAN user-msg (JIT plan)

> **Branch:** `m9-gateway-wan` (stacked on `m9-user-surfaces` HEAD — the Wave-2
> `receive_wan` / identity-gate code this extends). Rebase if Wave 2 changes.
> **Executor:** todlando · **Reviewer/gate:** doyle.
> **Doc stage already landed** (this branch's opening commit, doyle-authored):
> CONTEXT §Gateway posture + ROADMAP open-thread + this plan. You own impl/unit.

## Goal

Complete the `user-msg` authority story across the WAN. Today `receive_wan`
re-stamps **every** WAN `user-msg` to plain `msg` (`wan.rs` ~131,
`origin_user_backed` hardcoded `false`) — the fail-closed residual. Make a
`user-msg` from a **Gateway-typed** origin survive the funnel as `user-msg`,
keyed on the **QUIC-handshake-proven origin node**.

## Ratified trust posture (the contract — deviation = STOP-and-ping)

The **subnet membership boundary is the trust boundary** (operator-ratified
2026-06-13; CONTEXT §Gateway). A subnet is machines the user already trusts:

- A `user-msg` from a Gateway-typed origin over the subnet **is honored**.
- **Do NOT** build any defense against a subnet member *forging* the Gateway
  type. An in-subnet compromise is out of scope by construction. No per-node
  "user-surface set", no signed-type attestation, no extra gate — that was
  explicitly vetoed as overkill. Resist re-adding it.
- Keying on the **QUIC-proven origin node** (not wire `from`) stays — but that
  is **correctness** (how the registry is addressed), not a trust layer.

If you find yourself adding a trust check beyond "is the advertised type
gateway", stop and ping doyle — you've left the ratified posture.

## REQ (survey + mint — your call per M9 convention)

The work spans two existing reqs; mint as cleanest:
- Recommend a new **`REQ-MSG-6`** "cross-node Gateway `user-msg` honored via
  advertised `endpoint_type`" `[doc,impl,unit]`. Doc stage = the CONTEXT posture
  para (already tagged `[doc->REQ-MSG-5]`; add `[doc->REQ-MSG-6]` beside it or
  retag — your survey).
- The `Instance.endpoint_type` field extends **REQ-INST-7**'s data model — add
  its impl/unit evidence there, or fold under `REQ-MSG-6`. You decide; keep
  evidence on the real symbol, same-commit.
- Update **REQ-EP-6**'s title: drop the "deferred past M9" clause now it's
  active.
- `traceable check` EXIT=0 at every commit (activate stages only as you satisfy
  them — don't pre-fail impl/unit before they land).

## Tasks

**T1 — `Instance.endpoint_type` (impl).** Add `endpoint_type: Option<String>`
(or `Option<EndpointType>` if it serdes cleanly over the wire — prefer the open
tag string to keep the taxonomy open, mirroring `GATEWAY_TAG`) to
`spt-net/src/net/registry.rs` `Instance`. **Additive serde-default,
`skip_serializing_if = "Option::is_none"`** — exactly like `resources` /
`last_active`: a pre-existing / N-1 row parses clean. It rides the existing
epoch-leased `RegistryUpdate` replication — **no new channel, no new merge key**
(`merge_instance` precedence stays the author-node epoch).

**T2 — populate on advertise (impl).** Where `advertise_local` builds the
`Instance` for a local endpoint, set `endpoint_type` from the endpoint's own
record (the info.json type / `state="gateway"` that T4's `classify_local_origin`
already reads, or the manifest hostable type). A node only ever advertises its
**own** endpoints' types — no cross-node authorship.

**T3 — resolve at the funnel (impl).** In `receive_wan` (`spt-daemon/src/wan.rs`),
after the access gate + dedup, resolve the origin endpoint's advertised
`endpoint_type` from the registry **keyed by the QUIC-proven `origin_node`** (the
arg already documented as `remote_id_hex`), for the `msg.from` endpoint id.
Confirm that endpoint's Instance is hosted on `origin_node` (it is, by registry
keying). Derive `origin_user_backed = endpoint_type advertises GATEWAY_TAG` and
pass it into `restamp_wan_user_msg(&msg.body, origin_user_backed)` — flip the
hardcoded `false`. Absent/unresolvable type → `false` (rollout grace).

**T4 — unit test matrix (unit).** Pin every row:
| origin advert | wire | expect |
|---|---|---|
| `endpoint_type=gateway`, hosted on proven node | `user-msg` | **honored** (stays `user-msg`) |
| `endpoint_type=<agent/other>` | `user-msg` | re-stamped to `msg` |
| no `endpoint_type` advertised (N-1 node) | `user-msg` | re-stamped (rollout grace) |
| `from` claims a gateway id but proven node hosts no such gateway / different type | `user-msg` | re-stamped (keying on proven node, not `from`) |
| any of the above | plain `msg` | untouched |
Keep the existing `wan.rs` + `wanmsg.rs` tests green (the fail-closed default
test now becomes "no-advert → re-stamp").

**T5 — int (optional, `[twohost]`-tagged).** Two-host rig: node A advertises a
Gateway-typed endpoint, sends `user-msg` to an agent on B → B honors it; an
agent-typed origin on A → B re-stamps. Mirrors `tests/twohost.rs` shape. Skip if
the unit matrix + existing wire-convergence tests cover it; flag if you skip.

## Evidence + gate

- `[impl->REQ-MSG-6]` / `[unit->REQ-MSG-6]` on the real symbols (Instance field,
  advertise populate, `receive_wan` resolve, the matrix test). `[doc->...]` is
  the CONTEXT para (this branch's opening commit).
- Full workspace sweep 0-fail · clippy -D · `--no-default-features` builds ·
  `xtask check` OK · `traceable` EXIT=0 · schema re-blessed if Instance serde
  changed the manifest/registry schema.
- PR base = `m9-user-surfaces` (stacked, clean diff) — same vehicle pattern as
  Wave 2. CI both runners.
- **doyle gate:** I'll review the trust-posture adherence (no smuggled extra
  gate), the additive/N-1 tolerance of the Instance change, the proven-node
  keying (not wire `from`), and the matrix. Ping at CI-green.
