## 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

All on HFENDULEAM, head `c0f48e54`, own pool, `-j2`.

| Check | Result |
|---|---|
| Targeted nextest (`-p spt-net -p spt-daemon -p spt`, lib + `spt` bin + `propagate`, `docs_bundle_e2e`, `peer_docs_e2e`) | 111 run, 111 passed |
| Workspace clippy `--all-targets -D warnings` | exit 0 |
| `traceable-reqs check` (0.4.1) | exit 0; the three new REQs OK at doc/impl/unit/int |
| `xtask gen` + `xtask check` | exit 0 |

New units (each confirmed PASS by name in the log): `asset_wire_names_round_trip_and_unknown_is_none` and the extended `records_round_trip_and_unknown_kind_is_skipped` (spt-net); `retained_docs_survive_only_a_set_that_signs_them`, `asset_loads_only_for_the_exact_staged_version`, `docs_landed_marker_round_trips` (relcache); `asset_chunks_place_by_offset_and_the_ceiling_refuses`, `pulled_docs_are_admitted_only_against_the_signed_digest` (propagate); `docs_pull_cooldown_is_per_peer_and_per_version` (pump); the `fetch_asset` row in `classifies_every_family_by_first_line_shape` (dispatch); `unstaged_docs_skip_is_loud_only_for_declared_unlanded_docs`, `retained_docs_reland_only_when_needed` (cli).

Ints:
- `peer_docs_e2e::a_peer_pulled_set_lands_the_peers_version_matched_docs`: node A lands docs through the real `spt update apply` and retains them. Node B pulls the set from A over two in-process brokers, then the docs through the asset leg, and lands them through its own real apply. Also covers the untrusted-origin and version-mismatch refusals, and a pre-fix node A2 that serves nothing, after which node C's apply prints `UPDATE_DOCS_SKIPPED: signed set declares docs but none staged` and the binary outcome is unchanged.
- `peer_docs_e2e::a_silent_peer_costs_one_reply_budget_as_an_ordinary_error`: nobody serves the opener, the same silence an N-1 peer gives. The pump-mode requester (io timeout 2 s) returns a non-`TimedOut` error after one budget (2.17 s), and the same brain completes a real exchange right after.
- `docs_bundle_e2e` updated: leg 1 now asserts retention plus the landed digest; new leg 1b re-applies with a fresh spt copy and gets no docs token and an unchanged tree mtime; new leg 4 is the declared-but-unstaged loud skip.

Negative control (run, then reverted): with the pre-fix `cache.clear_staged_docs()` put back after landing, `a_peer_pulled_set_lands_the_peers_version_matched_docs` FAILS at `peer_docs_e2e.rs:332`, "A RETAINS the landed bundle so it can serve peers", left `None`. Exit 100. Source restored and re-checked.

Not run locally: the full workspace nextest and the Linux leg. Nothing in W1 is cfg-gated.

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
