# W1 gate — c0f48e54 (doyle, 2026-09-24 09:11Z)

nextest (spt-net+spt-daemon lib, spt bins, peer_docs_e2e, docs_bundle_e2e): 2075 run / 2075 passed (3 leaky) / 1 skipped, exit 0. treqs 0.4.1 exit 0, 944==944. Clippy --workspace --all-targets -D warnings: exit 0 (09:12Z). Review below (read-only agent, opus).

# Read-only design review — c0f48e54 vs 3672c25c (PR spt-bs-core#250)

Worktree `.worktrees/gate-c0f48e54`. No build, no cargo, no state-changing git. Paths are repo-relative to that worktree.

## 1. Serve-side roster gate + one indistinguishable refusal — VERDICT: HOLDS

`crates/spt-daemon/src/propagate.rs:180-231`. The arm runs the identical predicate the `Query` arm runs at `propagate.rs:238` (`let trusted = roster.is_member_any(origin_node);` — same expression, same `origin_node` which `dispatch.rs:1144` sources from the stream table, hazard 7.5).

```rust
let trusted = roster.is_member_any(origin_node);                       // :192
let held = if trusted {
    UpdAsset::parse(&asset).and_then(|a| cache.load_asset(&a, version)) // :194
} else { None };
let Some(bytes) = held else {
    let line = UpdRecord::Err { upd_id, message: ASSET_NOT_HELD.to_string() }.encode_line();
    brain.net_stream_send(stream_id, &line, None, true)?;               // :203
    return Ok(if trusted { UpdateServeOutcome::AssetNotHeld }
              else { UpdateServeOutcome::Refused });                    // :205-208
};
```

One record shape for all four refusal causes (untrusted / unparsable asset name / absent file / version mismatch); `ASSET_NOT_HELD = "asset not held"` at `crates/spt-net/src/net/update.rs:135`. The outcome enum distinguishes them *locally only* (never on the wire). Both arms proven end-to-end at `crates/spt/tests/peer_docs_e2e.rs:404-418` (untrusted → `Refused`, wrong version → `AssetNotHeld`, both read `AssetPullOutcome::NotHeld` at the requester).

Minor: the untrusted arm short-circuits before the disk read, so a timing side channel exists (sub-ms vs a file read). Not a design-ruling violation; note only.

## 2. Exact version match only — VERDICT: HOLDS

`crates/spt-daemon/src/relcache.rs:370-375`:

```rust
pub fn load_asset(&self, asset: &UpdAsset, version: u64) -> Option<Vec<u8>> {
    if self.staged_version() != Some(version) { return None; }
    std::fs::read(self.asset_path(asset)).ok()
}
```

No ordering comparison anywhere; no "latest" fallback. Covered by `relcache.rs:701-724` (`older version` / `newer version, never closest` / `declared but absent` / `empty cache`) and the integration arm at `peer_docs_e2e.rs:412-418`.

## 3. Requester verifies against the signed set's digest; nothing staged before the check — VERDICT: HOLDS

`propagate.rs:815-829`:

```rust
match verify_update_set_docs(meta, bytes) {
    Ok(()) => { cache.stage_docs(bytes)?; Ok(DocsPullOutcome::Staged { version: meta.version }) }
    Err(reason) => Ok(DocsPullOutcome::Rejected(reason)),
}
```

`meta` comes from the node's OWN staged signed set (`docs_wanted`, `propagate.rs:782-789`), never from the stream. `verify_update_set_docs` (`crates/spt-daemon/src/release.rs:662-678`) hashes the bytes and compares to `meta.docs.sha256`. On mismatch `stage_docs` is never called — no file written, nothing to remove; `relcache.rs:329-332` is the only writer. Unit proof at `propagate.rs:1123-1153` (`TAMPERED` → `Rejected(ArtifactMismatch)`, `cache.staged_docs().is_none()`, `staged_version()` still 8 → binary set untouched). Pump side is loud on reject: `pump/update.rs:401-403` `UPDATE_DOCS_REJECTED:{peer}:{reason}`.

Note (not a defect, pre-existing posture): `docs_wanted` trusts the on-disk `metadata_json` without re-checking `signature_hex` — same trusted-on-stage assumption `land_staged_docs` already makes. Local-FS write is already game over for the binary path.

## 4. Old-peer arm drops on the reply-read deadline as a per-peer failure — VERDICT: HOLDS

Deadline: `propagate.rs:692-696` — `brain.reply_read_deadline()`, re-armed on data for this stream, read through `read_peer_reply_until`. That is byte-for-byte the `request_update` pattern at `propagate.rs:474-480`. `reply_read_deadline` = `min(io_timeout, PEER_REPLY_READ_BUDGET)` (`brain.rs:1050`, `:2685`), and `reclassify_peer_reply_err` (`brain.rs:2696-2705`) maps the resulting `TimedOut` **out** of `TimedOut` so `peer_outcome` drops only that peer. `pump/update.rs:405-409` catches every non-`TimedOut` error, logs `UPDATE_DOCS_PULL_FAIL`, and cools the peer down; only a genuine carrier `TimedOut` re-raises (`:405`).

Test: `crates/spt/tests/peer_docs_e2e.rs:469-499` `a_silent_peer_costs_one_reply_budget_as_an_ordinary_error` — nobody serves the stream, `assert_ne!(err.kind(), TimedOut)` at `:482`, then the same brain completes a real exchange at `:493-498` (not wedged). Dispatch side of "unknown kind skipped" is the `fetch_asset` classifier at `dispatch.rs:263`; an N-1 daemon lacks it and resolves Unknown.

## 5. `UPDATE_DOCS_SKIPPED:` loud on declared-but-unstaged — VERDICT: HOLDS

`crates/spt/src/cli.rs:9745-9754` + the call at `:9784-9793`:

```rust
let Some(bundle) = cache.staged_docs() else {
    let declared = staged_meta.as_ref().and_then(|m| m.docs.as_ref());
    if let Some(line) = unstaged_docs_skip_line(declared.map(|d| d.sha256.as_str()), landed.as_deref())
    { eprintln!("{line}"); }
    return;
};
```
Line text: `"UPDATE_DOCS_SKIPPED: signed set declares docs but none staged — docs retry next fetch"` (`:9753`). Silent only for a docs-less set and for a set whose declared digest equals the recorded landed one (`:9750-9752`). Unit `cli.rs:35558-35572`; integration legs `docs_bundle_e2e.rs:260-275` (leg 4) and `peer_docs_e2e.rs:456-462` (node C against a pre-fix peer).

## 6. Retention after landing, bounded to the applied version — VERDICT: HOLDS

- `clear_staged_docs()` after landing is **gone**: `cli.rs:9869-9875` now writes `cache.record_docs_landed(&bundle_sha256)` instead. Repo-wide, the only remaining callers are the two staging paths and one test — `relcache.rs:239`, `:305`, `:633` (grep `clear_staged_docs`, 4 hits incl. the definition at `:345`).
- Bounded to one bundle: `relcache.rs:296-307` keeps a retained bundle across a restage only when the incoming set's `docs.sha256` equals `sha256_hex(bundle)` (case-insensitive), else drops it; `relcache.rs:236-239` drops it on a single-release stage. Unit `relcache.rs:660-698`.
- Serving after apply works because `spt update apply` never clears the staged set (no `clear_staged` symbol exists), so `staged_version()` still answers. Integration proof `peer_docs_e2e.rs:333-338` (A retains after a real apply) and `:420-433` (B pulls exactly those bytes).
- Re-extraction guard: `staged_docs_already_landed` (`cli.rs:9760-9766`, used at `:9811`) also requires `docs_dir.is_dir()`, so a deleted docs tree re-lands.

## 7. Hazards — VERDICT: HOLDS, with two forward-looking notes

- **D5b / fire-and-forget sends:** every new send passes `op = None` and journals nothing — `propagate.rs:203` (refusal, `finish=true`), `:219` (chunks, `finish=false`), `:227` (Done, `finish=true`), `:688` (the opener, `finish=true`). `net_stream_send` with `op=None` returns immediately without an ack wait (`brain.rs:2119-2121`). Identical to the artifact serve at `:295-303` and the status query at `:859`. The stream open is non-journaled too (`propagate.rs:659`, `net_open_stream(conn_id, None)`).
- **Unbounded brain read:** none introduced. The serve loop reuses the existing `call_deadline()` at `propagate.rs:134`; the pull loop uses `reply_read_deadline()` (`:692`). In a non-pump (`cold_start`) carrier both are `None` by construction — that is the pre-existing carrier contract, and the only production caller is the pump; the e2e requester correctly uses `Brain::cold_start_pump` (`peer_docs_e2e.rs:238-246`).
- **Unverified staging:** none. See item 3.
- **Panic/unwrap on wire data:** none found. `decode_bytes` errors are mapped (`propagate.rs:712-714`); `place_chunk` (`:747-761`) uses `usize::try_from` + `checked_add` + a `<= ASSET_PULL_MAX_BYTES` filter, so `offset = u64::MAX` is an ordinary error (unit `:1090`); the slice `bytes[end - chunk.len()..end]` cannot underflow because `end = offset + chunk.len()`. Path traversal is closed: `asset_path` routes an adapter name through `sanitize_platform` (`relcache.rs:576-587`), unit-checked at `relcache.rs:722-724` (`../../evil` → `.._.._evil.spt` under `adapters/`).
- **Note A (forward risk):** `request_asset_on` ignores `Done { total }` (`propagate.rs:715-717`, `..` binding). Harmless for docs because the digest gate catches truncation, but `UpdAsset::Bundle` / `Adapter` have no verification wrapper yet — a later wave must not call `request_asset` directly without one. Worth a `total` check or an assert at that time.
- **Note B:** the byte ceiling is requester-side only; the serve side reads the whole asset into RAM (`relcache.rs:374`) with no cap. Self-inflicted only (the node wrote the file), so not a defect.

## 8. Tests — VERDICT: HOLDS, one timing-budget flag

New/changed:

| Test | Proves |
|---|---|
| `dispatch.rs:2307` (in `classify_first_line` test) | `fetch_asset` classifies to `StreamFamily::Update`. |
| `net/update.rs:241-245` (in the round-trip test) | `FetchAsset` encodes/decodes over NDJSON. |
| `net/update.rs:269-280` `asset_wire_names_round_trip_and_unknown_is_none` | wire names round-trip; `adapter:`, `firmware`, `Docs` all parse to `None` (no case folding, no guessing). |
| `propagate.rs:1079-1094` `asset_chunks_place_by_offset_and_the_ceiling_refuses` | out-of-order + replayed chunks reassemble idempotently; ceiling and `u64` overflow are non-`TimedOut` errors that write nothing. |
| `propagate.rs:1126-1153` `pulled_docs_are_admitted_only_against_the_signed_digest` | `docs_wanted` gating; tampered bytes → `Rejected`, nothing staged; good bytes → `Staged`; binary set untouched. |
| `relcache.rs:663-698` `retained_docs_survive_only_a_set_that_signs_them` | at most one retained bundle; kept on a matching restage (incl. upper-case digest), dropped by a different-docs set, a docs-less set, and a single release. |
| `relcache.rs:704-725` `asset_loads_only_for_the_exact_staged_version` | exact-version-only serve; no traversal out of `adapters/`. |
| `relcache.rs:729-736` `docs_landed_marker_round_trips` | landed-digest marker lowercases and round-trips; absence is `None`. |
| `pump/update.rs:428-438` `docs_pull_cooldown_is_per_peer_and_per_version` | cooldown is per peer and per set version; passes at `t0 + DOCS_PULL_RETRY`. Uses synthetic `Instant` arithmetic, **not** a sleep — good. |
| `cli.rs:35561-35572` `unstaged_docs_skip_is_loud_only_for_declared_unlanded_docs` | loud exactly for declared-and-never-landed; silent for docs-less and already-landed (case-insensitive). |
| `cli.rs:35577-35583` `retained_docs_reland_only_when_needed` | no re-extract when digest matches AND the tree exists; re-lands otherwise. |
| `peer_docs_e2e.rs:313-467` `a_peer_pulled_set_lands_the_peers_version_matched_docs` | full A→B path over real brokers: A retains after a real apply, B pulls the set then the docs, both serve-gate refusal arms, B lands real docs; plus a pre-fix negative control (A2 serves nothing, C is loud). |
| `peer_docs_e2e.rs:469-499` `a_silent_peer_costs_one_reply_budget_as_an_ordinary_error` | silent peer → ordinary error, brain still usable. |
| `docs_bundle_e2e.rs:178-215` (leg 1b, inside the existing test) | repeated apply prints no `UPDATE_DOCS` token and does not re-extract (mtime unchanged). |
| `docs_bundle_e2e.rs:260-275` (leg 4) | declared-but-unstaged → loud skip, binary outcome unchanged, no docs tree. |

**Flag — one product-time-budget wait:** `peer_docs_e2e.rs:487-488`

```rust
assert!(waited >= io_timeout, "waited one reply budget: {waited:?}");
assert!(waited < io_timeout * 5, "and not much more: {waited:?}");
```

`io_timeout` is 2 s (`:473`), so the upper bound is a 10 s wall-clock assertion on a shared runner — the class of red memory records as "new test racing a product budget reds under load". The load-bearing assertion (`assert_ne!(err.kind(), TimedOut)` at `:482`) is state, so the upper bound is decoration; suggest loosening or dropping `:488` rather than shipping a box-measuring assert. `:487` (lower bound) is safe.

Also `connect_retry` / `pump_brain` / `wait_for_stream_except` poll with bounded retries and exit on first sight (`peer_docs_e2e.rs:227-259`) — correct shape, no fixed sleeps.

## 9. traceable-reqs.toml — VERDICT: HOLDS

Three new ids, all `required_stages = ["doc", "impl", "unit", "int"]` (`traceable-reqs.toml:7852-7864`), and all three added to the group roster at `:4895-4897`.

- `REQ-UPDATE-PEER-ASSET-LEG` — doc `docs-site/src/self-update/overview.md:107` (immediately above the "Docs travel with the release." paragraph) + `CHANGELOG.md:17`; impl on the real code at `net/update.rs:77,134,140`, `dispatch.rs:262`, `propagate.rs:56,83,190,634,652,746,765,781,796,814`, `relcache.rs:60,64,352,369`, `pump/update.rs:38,43,373`; unit on the tests at `net/update.rs:240,265`, `dispatch.rs:2306`, `propagate.rs:1076,1123`, `relcache.rs:701`, `pump/update.rs:423`; int `peer_docs_e2e.rs:20`.
- `REQ-UPDATE-DOCS-RETAINED-SERVABLE` — doc `overview.md:108`, `CHANGELOG.md:18`; impl `relcache.rs:69,238,298,379,389`, `cli.rs:9759,9810,9872`; unit `relcache.rs:660,727`, `cli.rs:35574`; int `docs_bundle_e2e.rs:18`, `peer_docs_e2e.rs:21`.
- `REQ-UPDATE-DOCS-UNSTAGED-SKIP-LOUD` — doc `overview.md:109`, `CHANGELOG.md:19`; impl `cli.rs:9744,9787`; unit `cli.rs:35557`; int `docs_bundle_e2e.rs:19`, `peer_docs_e2e.rs:22`.

Every `impl`/`unit` tag sits on or immediately above the item it names (I checked each line); none is a file-top coverage tag.

**One note, not a failure:** the `int->` tags in `peer_docs_e2e.rs:20-22` are in the file header rather than on either `#[test]`, and that file carries **two** tests. This matches the pre-existing convention in `docs_bundle_e2e.rs` (its `[int->REQ-DOCS-RELEASE-ASSET]` is also a file-header tag), and for an int file the module is plausibly the evidence — but a reader cannot tell from the tag which of the two tests carries `REQ-UPDATE-DOCS-UNSTAGED-SKIP-LOUD` (it is the first one, `:456-462`). Moving the tags onto the two `#[test]` fns would cost three lines and remove the ambiguity.

## Summary

All nine ruling conditions hold. Nothing blocks. Two cheap cleanups worth folding in before merge: drop or widen the `waited < io_timeout * 5` wall-clock assert (`peer_docs_e2e.rs:488`), and move the `int->` tags onto the two `#[test]` fns. One forward-looking item to carry into the bundle/adapter wave: `request_asset` is public and unverified by itself, and `Done{total}` is ignored — the next caller must bring its own digest gate.
