computedriven computedriven/docs/r09-nc35-kernel-leg-DEVIATIONS.md
The plan, docs/r09-nc35-kernel-leg-plan.md, is frozen as it stood at the R108 waiver (sha256 feeb143125eced8ae836ce78d6e43cfe1be8d7d467b624cf403a5ad7a7d75418) and is not edited.…

R0.9 nc35 kernel leg — deviations from the frozen plan

The plan, docs/r09-nc35-kernel-leg-plan.md, is frozen as it stood at the R108 waiver (sha256 feeb143125eced8ae836ce78d6e43cfe1be8d7d467b624cf403a5ad7a7d75418) and is not edited. Every place the build departs from it, or has to choose where it is silent, is recorded here with the reason. A reviewer who reads only the plan and this file should be able to predict what was built.

D1 — the runner is outside the repository, so leg 18's wiring cannot be in the falsification commit

Plan §5.1: the falsification commit has lab/scripts/nc35k_kernel_leg.sh "wired as battery leg 18 of run-r09.sh, with the four-places check".

What is true on disk: run-r09.sh and series-r09.sh live in ~/computedriven-r05-qualification/runner/, which is not a git repository. The four places of R0.9-OPEN §7 are split across two trees: tools/guards.sh (LAB_SCRIPTS) and tools/verify_battery.py (SUBJECT_MARKER) are in the repository; run-r09.sh (the rc_ init, the run_leg call, the FINAL conjunction, the jq arguments and the banner) and series-r09.sh (the unit: line) are not.

What was done: the in-tree half is in the falsification commit. The runner half was edited in place by a script that asserts every anchor occurs exactly the expected number of times and that no apostrophe entered the single-quoted runner body (R0.9-OPEN §7's second launcher defect). The before and after SHA-256 of both runner files and the unified diff are kept in ~/build/r09kleg/logs/ on the PX13 and quoted in the live record (receipts/R0.9-NC35-KERNEL-LEG.md). A bundle that ships the runner (make-r09-bundle.sh) will pick up the edited file; nothing else versions it.

The same launcher constraint applies to run-r09-nc35k.sh, a second file added beside run-r09.sh in the same directory. It runs the leg alone, or the mutant battery (§5.3, which is not a battery leg), in the same runner image with run-r09.sh's docker flags, mounts and working directory unchanged. It is outside the repository for the same reason; its SHA-256 is in the live record.

D2 — the leg has its own three-host topology, not the routed R0.x underlay

Plan §2: "containerlab topologies from lab/clab-cd-r0x/." On disk there is no such directory: lab/clab-cd-r0N/ are containerlab's generated state directories (gitignored), and the R0.x topologies are lab/topology*.clab.yml, a four-router FRR/BGP underlay. §5.2 asks for "the leg's topology", and the leg's claims are about whom the kernel's WireGuard authenticated, not about routing. Built: lab/topology-r09k.clab.yml, in the R0.x style and on the same image (cd-node-lab-wg:r0, built by run_r08.sh from lab/Dockerfile.cd-node-wg), with three hosts and two point-to-point links: init (192.168.35.1 / 192.168.36.1), resp holding K1 (192.168.35.2) and decoy holding K2 (192.168.36.2). Every node deletes containerlab's management default route, as every R0.x topology has since R0.5.8, so "no route" can be set up honestly. Every name carries the prefix r09kleg (containers r09kleg-nc35k-<node>, network r09kleg-mgmt, 172.35.35.0/24), because another lane shares the host tonight. WireGuard interfaces are made the same way lab/scripts/wg_overlay*.sh makes them (ip link add wg0 type wireguard, wg set), by the leg itself, because the leg needs a fresh interface per case and different peers than either overlay script configures; the two overlay scripts are untouched (their hashes are inside frozen R0.6/R0.7 receipts).

D3 — keys are fresh per CASE, and no private key touches a file even inside a container

Plan §3: "generated per run with wg genkey inside the containers, loaded with wg set … private-key." Built: every case deletes wg0 everywhere and creates it again with wg genkey | wg set wg0 … private-key /dev/stdin, so each case has its own KI, K1 and K2, every baseline handshake is 0, and no state crosses from one case to the next. The private key exists only in the pipe and in the kernel; wg_overlay*.sh write theirs to /tmp/wg.key in the container, which this leg does not. wg show wg0 dump's first line carries the private key; it is dropped inside the container (tail -n +2) by the leg and skipped at the parse by KernelWgPeer. Receipts carry fingerprints only: the first 12 hex digits of SHA-256 over the 32 decoded bytes. A final gate scans everything the leg captured for any 44-character base64 key and requires none.

D4 — where the plan is silent about case A's other peer, the strongest reading was built

The plan says A's endpoint "is held by a DIFFERENT WireGuard peer (K2)" and nothing more. Built: K2's holder is a WORKING peer — it has the initiator's key configured, so it would complete a handshake if the initiator expected K2 — and its cd-node claims N1, the right NodeId. A therefore asks whether a correct NodeId behind the wrong transport peer gets through; it must not, and the transport step must be what stops it. Both responders bind 0.0.0.0:47001; the probe addresses the responder only at its tunnel address, so its datagram can only travel through WireGuard.

D5 — U is built as "the initiator's interface toward the endpoint is down, so there is no route"

Plan §1: "The endpoint's interface is down, or no route." Built: init's underlay interface toward the endpoint (eth1) is set down; with the management default route deleted, the kernel then has no route to the endpoint (ip route get 192.168.35.2 → Network is unreachable), while wg0 stays UP, so U isolates "no route". The other reading — the responder's own interface down — was rejected: from the initiator, unanswered handshake initiations look identical whether nobody is there or the wrong peer is there, so no local kernel evidence could separate it from A, and a KernelWgPeer honest to "kernel evidence only" would have to call it NotAuthenticated. Everything else in U is E's setup, so U differs from E by the link alone.

D6 — how a case is judged: the plan's two halves, plus a window and a reached check

The plan's §1 requirements are exactly two gate groups per case: K (kernel evidence) and C (connector result). Two more are added under R0.9-OPEN H2 and H3: W, the setup is the setup the case claims, checked before the probe (the initiator holds exactly K1, never handshaken, no endpoint; the endpoint's holder holds the key the case names and answers the NodeId it names; routable or, for U, not), and R, the probe's traffic reached the case's subject (E/B: the holder's cd-node heard the HELLO; A: initiations left for K1 and arrived at the decoy's WireGuard socket, and nothing reached the decoy's application). The K group is read by the leg itself from wg show wg0 dump after the probe, never taken from the probe's own report. A's "stays 0 for the whole bound T" is read no earlier than t_start + T + 1 s, whatever the probe did.

D7 — the order in which KernelWgPeer decides the three outcomes

The plan defines Authenticated / NotAuthenticated / Unreachable but not the order of the checks. Built: (1) if wg0 is down or ip route get <endpoint> fails, Unreachable, and nothing is sent; (2) one HELLO into the tunnel; (3) poll wg show wg0 dump every 100 ms for the expected peer's latest handshake to exceed its baseline and be no older than the probe's start, up to T = 10 s (a pub const, never a parameter); (4) if none, Unreachable if the interface or route is gone at the end, else NotAuthenticated. A reply is used only as the datagram the connector parses, never as evidence of authentication.

D8 — the mutant set is the plan's four plus four, and its reference names gate groups, not only cases

Plan §5.3 names m1–m4 and the cases each must turn red. They are built exactly as named. Four are added, because the build's brief asks that every law have a planted bug ONLY it catches (or a declared overlap) and that the plan's named traps each be planted:

idplantedplan / reason
m1KernelWgPeer reports Authenticated without reading the handshake§5.3 m1
m2the handshake lookup compares hex of one key against base64 of the other§5.3 m2; R0.6's gate
m3the NodeId is not compared (scratch copy of cd-connect)§5.3 m3
m4Unreachable folded into NotAuthenticated§5.3 m4
m5the NodeId comparison always refuses (scratch copy of cd-connect)E had no mutant of its own
m6trusted setup never tells the responder the initiator's key§1's "a leg where nothing authenticates"
m7case B's endpoint is the one K2 holds§1's "a B that fails at transport"
m8the probe speaks to the endpoint in the clear and takes a reply as authenticationR0.9-OPEN H4; shows B's KERNEL half firing alone

The reference (lab/nc35k/mutants.expected) states, for each mutant, the exact set of (case, gate group) pairs that must go RED — finer than the plan's "which case" — and was committed before any mutant ran. Mutants are planted by exact find/replace with a declared anchor count into a SCRATCH COPY of the parts of the tree the leg builds from (cd-core, cd-wire, cd-connect, lab/nc35k, the leg, its topology); the working tree, and the tree's cd-connect in particular, are never edited. m0 plants nothing and must reproduce the all-GREEN result, so that no "catch" can be the scratch harness failing.

D9 — after execution (2026-10-02, R118): nc35's pin moved, because nc35's hollow section F was replaced

Plan §4.2: "the scripted nc35_layer_substitution.sh still passes, unchanged", enforced by nc35k_0_scripted_nc35_unchanged against a pinned SHA-256 (e2c48f55…4203). What changed, and why: Travis's R118 ruling closes R0.9-OPEN §9, and the N1 audit found nc35's section F hollow — its one gate, nc35_F_covered_by_nc34, tested that a FILE existed. R118 replaced it with a real control (F1: the session's accessors and fields carry only whom it reached, and nothing converts a session; F2: paired in cd-lifecycle — a legitimate grant moves authority, the real E session's facts recorded through bind move nothing), and case E now prints the session it returned so F2 can record it. Cases A, B, C, E, U and P assert exactly what they asserted. What was done: the pin was moved to the R118 version (053757c7…fe37), with the old value kept in the comment beside it. This leg's subject — A, B, E and U on kernel WireGuard — is untouched; its other gates are unchanged. The diff of nc35 is in receipts/R0.9-CLOSURE.md. Recorded after execution because it happened after execution.

D10 — after execution (2026-10-02, R128): one attempt per handle, the nonce from connect(), and a fake-kernel control

Plan §3 describes KernelWgPeer deciding one outcome from kernel evidence, and says nothing about a handle asked twice. What GPT's review 2 found (item 1): KernelWgPeer cached its authentication outcome and its answer in OnceCells that retarget() never invalidated, so a second connect() on a used handle would have validated a new candidate with the first attempt's evidence. The probe calls it once, so no case was affected, but nothing enforced it. What was done (R128, E-02): a handle serves exactly ONE attempt. Its first retarget begins it, whatever the outcome; a second is refused at the top of retarget, before any kernel call, as RetargetError::Refused with the note retarget: refused: this handle already served an attempt; one attempt per handle (D10). Refusing, not resetting, because the attempt's state includes the UDP socket's queue, where a datagram from the earlier attempt can arrive after any reset (the reasoning is in docs/r09-review2-fixes-plan.md, E-02). With E-01, cd_connect::connect makes each attempt's nonce itself and passes it to retarget; KernelWgPeer::open() no longer takes one, the HELLO carries attempt.value(), and the probe's PID-derived nonce is gone.

The control, in the leg (section 1b), before any lab exists: lab/nc35k/reuse_control.rs opens ONE handle and calls the real connect() twice, against a FAKE kernel — wg and ip stand-ins first on PATH that log every call and answer as one peer that handshakes once it has an endpoint — with the frozen cd-node's own serve_once on loopback as the far side. It is not kernel evidence, and the leg says so. Pseudo-case Q: W the first attempt is a session (so the handle's HELLO carried the attempt's nonce: the frozen responder echoes the HELLO's); R a second attempt was made; K no stand-in call during the second; C the second is Retarget(Refused) and the note names the rule. nc35k_mutants.sh reads Q's verdict line beside E, B, A and U, and its scratch copy now carries cd-node. Cases A, B, E and U are untouched: their setup, probe call and gates are as they were; that their outcomes did not move is measured in the R128 live record, against the leg's run at bceea0d before any change.

D11 — after execution (2026-10-02, R128): KernelWgPeer makes no query that prints a secret

Plan §3 says KernelWgPeer "reads the kernel's view with wg show <if> dump (or netlink)", and the file's own comment said private keys were "never read". What GPT's review 2 found: dump() captured the whole output of wg show <if> dump into the probe's memory, its first line being the interface's PRIVATE key, and skipped that line at the parse: skipped, not unread. No disclosure was demonstrated, and the claim was false. What was done: kernel_peers() replaces dump() and reads wg show <if> latest-handshakes, endpoints and transfer — each one line per peer, keyed by the peer's PUBLIC key, none printing a private or a preshared key — joined by decoded key, and refused rather than merged if the three reads disagree about the peer set. PeerRow and the handshake lookup are unchanged. The comment now says what the code does and what it did. The control: pseudo-case P in the same fake-kernel run: W the handle consulted the stand-in wg; K every wg call is show <if> latest-handshakes|endpoints|transfer or set <if> peer <k> endpoint <a> (the stand-in answers dump too, so a handle that asked for it would still work, and only this gate could see it).

Not changed, stated: the leg's own kdump helper still runs wg show wg0 dump INSIDE each container and drops the interface line there (tail -n +2), as D3 and the leg's header say: the private key passes through that pipe inside the container and never leaves it. Changing it would change the evidence lines the four cases are judged on, and the plan's §3 permits it. The final gate that no 44-character base64 key appears in anything the leg captured stays.

D12 — after execution (2026-10-02, R128): nc35's pin moved again

Plan §4.2: the scripted nc35_layer_substitution.sh still passes, unchanged, enforced by nc35k_0_scripted_nc35_unchanged against a pinned SHA-256. What changed, and why: R128's E-01 adds nc35's cases N, R, H, NW and Q and its S and T gates (an answer is accepted only as THIS attempt's ACK; the nonce is made by connect() from the OS; the trace is the whole road). Cases A, B, C, E, U and P assert what they asserted; B's answer is now a correctly correlated ACK, so it still reaches the NodeId comparison and fails there. What was done: the pin moved from R118's 053757c7…fe37 to R128's 0955a4e7…f38f, the old value kept in the comment beside it. Recorded after execution because it happened after execution.

D12, later the same evening (R128, before any planted bug ran): nc35's S gate scanned cd-connect's source only up to the first #[cfg(test)], so code appended AFTER the test module would have escaped it — found while writing the plant that appends a second constructor. The gate now removes each test item by brace matching, as nc42's and nc43's do. The pin moved once more, 0955a4e7…f38f → 0ef21c86…31ff, both values in the leg's comment. No case of either leg changed.

Open in the interactive atlas

content id · CIDv1
bagaaieralchykdsxf6wgusjywln5ke4i2gk57gdzqf2466xo5hywvnxberaq