# W4 plan — `feat/339-update-sources` (releases#339), todlando 2026-09-24 ~13:40Z

Branch off the W3-landed main sha doyle sends, once, and do not rebase. Rulings from MZSZAE5B are binding. Before the branch exists: no cargo.

## Invariants (these go VERBATIM in the PR body)
- **I1.** `sources.json` is an ordering hint and nothing else. No verify function reads it. Bytes from a pinned or learned source go through the same gate as any other peer's: `plan_verified` / `plan_verified_update_set` for core, `verify_update_set_docs` for docs, `verify_peer_adapter` + `check_archive_identity` for adapters.
- **I2.** A source is learned only AFTER the verify that admitted its bytes, never on an offer, a roster row or an Err.
- **I3.** Nothing in the pump ever blocks on another peer's in-flight dial. This sentence appears word for word as a comment at the skip site.
- **I4.** The GitHub fetch stays in the CLI. The daemon never talks to the release channel.

## REQs (every one is NEW; add to toml first, groups at mint, `required_stages` = all four)
Checked 13:38Z: none of these ids exists in traceable-reqs.toml. The existing neighbours are REQ-UPD-1, REQ-UPD-4, REQ-UPDATE-DEFAULT-COMPOSITE and the W3 REQ-ADAPTER-PEERS-FIRST.

| id | subject | group |
|---|---|---|
| REQ-UPDATE-SOURCES-STORE | `releases/sources.json` holds {node, pinned\|learned, last_served{what,version,at_ms}}; learn-on-serve at the four sites; hint-only (I1, I2) | self-update |
| REQ-UPDATE-SOURCES-LADDER-PUMP | UpdateWorker skip-to-next-round by class: pinned → learned → peers; owed clears on ask / round end / dial failure; no cooldown; I3 | self-update |
| REQ-UPDATE-SOURCES-COMPOSITE | the composite `spt update` core leg is peers-first (ranked), then the channel; `--remote` means channel only, `--via-subnet` means peers only, and the two conflict; verify unchanged; I4 | self-update + install-bootstrap |
| REQ-UPDATE-SOURCE-VERBS | `spt update source list\|pin <node>\|unpin <node>` | self-update |
| REQ-UPDATE-STATUS-VERB | NEW verb `spt update status` (it does not exist today; UpdateCmd = Apply/Fetch/Adapters) with a `sources:` line. W6 (#64 trust-anchor line, last-applied line) builds on this verb, so W4 mints it | self-update |

Changed in place (no new id):
- REQ-ADAPTER-PEERS-FIRST: adapter holders are ordered by (source rank, then highest version).
- W3's `PeerAdapterFetched.node` comes back in the SAME commit as its reader, which is learn-on-serve.

## Design (on the W3 substrate)
- **relcache.rs**: `Sources` type plus `ReleaseCache::{sources, record_served(node, what, version), pin, unpin}` and a pure `source_rank`. Written with an atomic write beside the W3 `retain_*`.
- **Learn sites**:
  - pump/update.rs `Updated`, served by `peer_hex`;
  - pump/update.rs `DocsPullOutcome::Staged`;
  - cli.rs `peer_update_candidate`;
  - cli.rs `install_via_subnet`;
  - the W5 bundle hook (fn exposed, not wired).
- **Pump**: UpdateWorker gets a `pre_round` that snapshots the ladder and resets `owed = {ranked sources in fan targets}`. In `peer_step`, a peer of class c is SKIPPED (not asked, not waited on) while any source of a higher class is still owed. After its own ask (any outcome), a source leaves `owed`. On a dial failure the source leaves `owed` immediately. At round end `owed` is cleared. The pure fn `ask_now(class, owed)` gets a unit test.
  - Open detail to settle in code: how the worker learns about a dial failure for a peer it never saw a step for. Options: the pump shell already records per-peer dial failure (the `sched` backoff), so read it at `pre_round`, or add a thin `on_dial_failed` hook. I will choose the smaller one and flag it in the PR.
- **Composite core leg**:
  - Peers first: dial ranked holders over the broker from the CLI (the W3 `pull_adapter_from_peers` shape, with core `request_update` instead). A verified stage from a peer ends the core leg; a peer miss falls through to `cmd_update_fetch` (channel).
  - `--remote` skips the peers.
  - `--via-subnet` skips the channel.
  - The adapters leg receives the same flag (the W3 `remote` bool plus a `via_subnet` route).
- **Verbs**: resolve `<node>` by label, key prefix or full hex through the registry snapshots (the existing `@node` resolver).

## Test binaries (unit tests live in-crate; the NEW integration binaries are below)
| binary | spawns a real daemon tree? | HEAVY at birth |
|---|---|---|
| `crates/spt/tests/update_sources_e2e.rs` — NO: the name contains `update`, and Windows UAC refuses such exes (os error 740). Renamed: **`crates/spt/tests/source_ladder_e2e.rs`** | YES: two real daemons (the W3 adapter_peer_e2e rig) plus a third peer C for ranking | YES. Enters BOTH <HEAVY> copies alphabetically, plus its own FURTHER EXPECTED SHIFT paragraph (W4, releases#339, Phase B +1 per OS, Phase A unchanged, no key literal in prose), in the commit that creates it |
| **`crates/spt/tests/source_verbs_e2e.rs`** (list/pin/unpin/status against a seeded home, no daemon) | NO: CLI only on a temp SPT_HOME. If it grows a daemon, it goes HEAVY in that commit | NO |

The pump ladder is unit-tested (`ask_now`, pre_round/owed transitions) rather than proven with an in-process pump int. That keeps the daemon count at one new HEAVY binary.

**Int arms in `source_ladder_e2e`** (one test, sequential steps, stated as such in the PR):
1. B learns A after A serves a newer adapter. `sources.json` records A as learned only after the verify.
2. A and C both hold a newer copy. B asks learned A first (the served-by line names A), even though C's copy is newer. Then B pins C, and C is asked first.
3. A pinned source serves tampered bytes. REJECTED, nothing staged, falls through (the I1 arm).
4. Composite `spt update --via-subnet` pulls core from a peer with the channel unreachable, and `--remote` never asks a peer.

Negative control first: break the step 2 served-by assert, watch it go RED, restore it.

## Gate hygiene learned on W3
- `xtask check` runs at design-close AND pre-push with CARGO_TARGET_DIR unset. It covers docs drift, tracker codes out of clap help, and HEAVY classification.
- `xtask gen` after any clap change; read the diff.
- No tracker codes in clap doc comments.
- The int is not in the thin lane: run it by hand with a negative control first.
- Test exe names never contain `update`.

## doyle rulings 13:39Z (KYO3C7DW) — BINDING, override the sections above
1. **Dial failure: all FOUR arms, none of them optional.**
   - (i) pre_round seeds `owed` ONLY with sources where the existing `peer_eligible(sched, hex, now)` (pump/mod.rs:176) is true.
   - (ii) clear `owed` on the failure events at mod.rs:783 (broker REFUSED the submit) AND mod.rs:1106 (async PRESENCE_DIAL_FAILED). Site 943 needs nothing.
   - (iii) clear `owed` at the NO-ROUTE branch mod.rs:790-808. It never touches sched and emits no event, so without this arm an unroutable pinned source wedges the ladder forever (pre_round re-seeds it every round). Do NOT give that branch a backoff: that changes pump semantics and is out of scope.
   - (iv) round end clears `owed` unconditionally. It is the load-bearing BACKSTOP for paths nobody enumerated, e.g. a submitted dial with no outcome by the round deadline stays `pending` and reschedules. Comment it as the backstop.
   - sched is RAM-only and re-primed on a supervised restart (mod.rs:633). The int must NEVER assert source ordering across a restart boundary.
2. **`spt update status` is IN the greenlit #339 ask ("carries a one-line sources: summary").** Build it. doyle records the clarification on the issue; I do not touch the board.
3. **W5 bundle hook: REFUSED.** Four learn sites only. W5 adds the fifth in the commit that wires it.
4. **Int step 4 mechanism:** run the child `spt` with `gh` removed from PATH. `cmd_update_fetch` → `gh_status` → `GhStatus::Missing` → it prints the gh-required refusal and exits 1 with ZERO network, so the result is deterministic and pays none of the ~2.05s Windows refused-connect cost.
   - `--via-subnet` / peers-first arm: assert that the gh-required line is ABSENT (the channel was never asked) and that core was staged from the peer.
   - `--remote` arm: assert the gh-required line is PRESENT and there are no peer lines.
   - No timing assertions anywhere.
   - To verify at build: find exactly how `gh_status` resolves `gh` (PATH lookup through run_bounded_command?) and strip PATH only in that child's env.
5. **REQ mint timing:** mint each of the 5 REQs in the SAME commit as its first evidence. Pre-push treqs exit 0 is the gate.
6. My ~13:33Z cargo DID overlap the start of the 36006386316 Windows leg (it started 13:33:31Z). doyle has recorded it as a countable caveat in the gate record.

## doyle 13:43Z (YA2RRH67) — step-4 ASSERTION corrected (binding)
Removing gh from PATH can give Missing, Unauthed or TimedOut depending on the platform: on Windows a .cmd shim fails with os error 193, not NotFound, so the result is Unauthed. NEVER assert which refusal fires.
Add a one-line channel-ATTEMPT marker, e.g. `UPDATE_CHANNEL_ATTEMPT:<repo>`, emitted in cmd_update_fetch BEFORE gh_status. Assert it:
- ABSENT on the peers-first and --via-subnet arms, plus core staged from the peer;
- PRESENT on the --remote arm, with no peer lines.
Keep stripping gh from PATH as the setup. If a NotFound spawn is ever needed, read spt_runtime::run_bounded_command first (it splits a command TEMPLATE; that splitting decides PATH resolution).

## doyle 13:44Z (V5WCRBNN): marker negative control (binding)
- An ABSENT assert on the marker passes vacuously if the marker is never emitted. So the step-4 negative control is: DELETE the emission at its site and watch the --remote PRESENT arm go RED. Then restore and run the pair. Prove PRESENT can go red before trusting ABSENT.
- The marker is emitted at ONE site only, on STDERR (never stdout: the --json wire contract). The PR body declares it as added by this lane.

## 13:55Z GO (doyle JAPGVZS6): W3 LANDED main == f6a55013. W4 worktree .worktrees/339-update-sources, branch feat/339-update-sources @ f6a55013 (ZERO rebases). W3 lane closed (target reaped 10.42 GB, worktree removed).

## 13:57Z HOLD W4 (doyle Q7VGI7A6 claimed -vs missing). I REFUTED with evidence (argv rewrite cli.rs:2272/2333, unit + int pass). Awaiting ruling (a) no lane / (b) thin lane adding visible_alias --vs off f6a55013. Do NOT start W4 build until doyle rules.

## 13:55Z HOLD LIFTED (doyle FMQSYNE6): ruling (a) no lane; #264 + #322 to acceptance; (b) refused. W4 GO — worktree .worktrees/339-update-sources @ f6a55013 already exists, proceed.

## doyle 14:10Z (PKBW3F2R) + 14:13Z (IMG7D3IU) — BINDING
- Q1 deferral APPROVED: a peer skipped in round N is asked in N+1 regardless of owed, but NEVER bypasses eligibility (Ladder.admit checks eligible FIRST). Unit pins the negative arm (ineligible peer with a pending deferral is not asked). ctx.eligible = fan targets passing peer_eligible. No test asserts ordering across a restart. Ladder lives on UpdateWorker (same lifetime as sched) — accepted.
- Q2 composite core candidates: every other known node (registry snapshot node_labels), ranked by the store's own class (no second ranking). doyle's peer_eligible tightening was REFUTED by me (sched is pump-thread RAM, the walk runs in the CLI); ACCEPTED replacement: filter on identity/pump-health.json — skip `failing` ∪ `node_offline`. Absent/corrupt file = no filter + UPDATE_PEER_HEALTH_UNKNOWN once. If the filter empties a non-empty set -> fall back to the RANKED UNFILTERED walk + UPDATE_PEER_HEALTH_ALL_FAILING (distinct wording). NO age bound on the file. No new backoff, no timeout tuning. Unit pins 4 arms (failing, node_offline, absent, all-filtered fallback).
- 5th learn site (composite core peer pull) APPROVED: same gate as pump Updated, calls the SAME record_served, a test must reach it (source_ladder_e2e step 4).
- LADDER-PUMP doc/impl/unit, no int — fine.
- CARGO GO 14:13Z (run 36008696673 green; main now 957d556d). At PR time rebase ONCE onto then-current main; expect traceable-reqs.toml conflict (IR-148) — resolve to the SHARED body, never one side whole.
