## W1 #331 — peer asset leg (releases#330)

A node that stages an update set from a peer now also gets that set's docs bundle from its peers, verifies it against the signed set, and lands version-matched docs on apply. Before this, only a node that fetched from GitHub got new docs (SCELTOUIN evidence on releases#330).

### What changed

- **Wire (`spt-net` `net/update.rs`).** New `UpdRecord::FetchAsset { upd_id, asset, version }` plus a typed `UpdAsset` (`docs` | `bundle` | `adapter:<name>`) and one refusal message `ASSET_NOT_HELD`.
- **Own stream (doyle ruling 7KELLZRT).** `FetchAsset` opens its own update-family stream, like `status_query`. It cannot follow an `Offer`: the artifact serve finishes the stream at `Done` (`propagate.rs` Done/UpToDate both return), and a node that already staged the set gets `UpToDate` on `Query`. The dispatcher routes `fetch_asset` to the update family.
- **Serve side (`serve_update`).** Same roster gate as `Query`. Untrusted origin, unknown asset, not held and version mismatch all answer the same `Err{ASSET_NOT_HELD}`. It serves only when `version` equals the staged set version exactly.
- **Requester (`request_asset`, `pull_staged_docs`).** Non-journaled open, bounded by the reply-read budget with re-arm per chunk, reassembly capped at 256 MiB. Bytes are staged only after `verify_update_set_docs` against the SIGNED set this node already holds.
- **Pump (`UpdateWorker::pull_missing_docs`).** After each peer's update pull, if the staged set signs docs and none is staged, ask that peer. Per-peer, per-version cooldown of 10 minutes after a miss. Only a carrier `TimedOut` propagates. Everything else is logged (`UPDATE_DOCS_STAGED` / `UPDATE_DOCS_REJECTED` / `UPDATE_DOCS_PULL_FAIL`) and never touches the binary stage.
- **Retention (`relcache`).** Landing no longer clears `docs.tar.gz`. Staging a set whose signed docs digest does not match the retained bytes drops them, and a single-release stage drops them too, so at most one bundle is held. `docs-landed.json` records the landed digest so a repeated apply does not re-extract.
- **Loud skip (`land_staged_docs`).** When the staged set declares docs and none is staged: `UPDATE_DOCS_SKIPPED: signed set declares docs but none staged — docs retry next fetch`. Silent for a docs-less set and for a set whose declared bundle is already landed.

### Trust argument

A peer relays bytes; it is never their authority. The requester admits docs only when their sha256 equals the `docs.sha256` inside the update set it already verified and staged under its own release-key policy (`verify_update_set_metadata` at pull time, re-checked by `land_staged_docs` before extraction). That is the same per-node gate the binary pull runs (REQ-UPD-2): one compromised roster member can at worst withhold docs or send bytes that are rejected loudly. The serve gate is the handshake-proven origin against the roster (REQ-HAZARD-WAN-ORIGIN-AUTH), with one refusal shape so an untrusted origin learns nothing.

### Old peers

An N-1 daemon classifies the `fetch_asset` opener as `Unknown` and drops it without answering. The requester reads with `read_peer_reply_until`, which reclassifies no-progress `TimedOut` into an ordinary error (REQ-PUMP-DIAL-FASTFAIL), so that peer costs one reply-read budget as a per-peer failure, then a 10-minute cooldown. It never wedges a round.

### REQs (new)

- REQ-UPDATE-PEER-ASSET-LEG
- REQ-UPDATE-DOCS-RETAINED-SERVABLE
- REQ-UPDATE-DOCS-UNSTAGED-SKIP-LOUD

### Tests

PROOF_PLACEHOLDER

Closes nothing by itself; releases#330 closes at the milestone.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_014j8UXzGXXsDsTJBYYUSCTC
