Skip to content

userspace-dp: resolve the to-zone of a MAC-less egress interface (IPsec xfrmi) - #6722

Open
psaab wants to merge 30 commits into
masterfrom
fix/6713-xfrmi-tozone
Open

userspace-dp: resolve the to-zone of a MAC-less egress interface (IPsec xfrmi)#6722
psaab wants to merge 30 commits into
masterfrom
fix/6713-xfrmi-tozone

Conversation

@psaab

@psaab psaab commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes #6713

Round 3 — the round-2 fix was inert; it has been removed

Rounds 1 and 2 argued about whether the fallback could hand an "unzoned" st0.0
its zoned sibling's zone. Round 2 added an ifindex_own_zone_id map to prevent
it. That map never had an effect at runtime, because it could not: it was built
from InterfaceSnapshot.zone, and the Go builder has already propagated.

buildInterfaceZoneMap (pkg/dataplane/userspace/zones.go) writes out[base]
for a unit-suffixed zone reference, and snapshotLinuxName collapses a non-VLAN
unit 0 onto the base netdev — so zoning st0.1 puts vpnb on the very ifindex
unit 0 forwards out of, on the BASE row, before Rust ever sees it.

Measured on the fixture's own config with the real builders
(buildInterfaceZoneMap + buildInterfaceSnapshots, buildLinkSnapshot stubbed
— it is a package var for exactly this):

zoneByInterface = map[ge-0/0/1:lan ge-0/0/1.0:lan st0:vpnb st0.1:vpnb]
snap name="st0"   linux="st0"   ifindex=42 parent=0  zone="vpnb"  hw=""
snap name="st0.0" linux="st0"   ifindex=42 parent=42 zone=""      hw=""
snap name="st0.1" linux="st0.1" ifindex=43 parent=42 zone="vpnb"  hw=""

The round-2 Rust fixture gave that base row no zone — a snapshot the builder
never emits. And the general case follows: a Rust child→parent propagation can
only add an entry for parent_ifindex(U), which IS the base row's ifindex, whose
own Zone is non-empty in both zone-ref spellings. So ifindex_own_zone_id and
ifindex_to_zone_id were the same map on every producible snapshot.

Round 3 accepts the propagated behaviour and documents it (see "What a shared
ifindex resolves to" below) rather than carrying an own-vs-inherited flag across
the Go→Rust boundary. Deleted: the map, its insert, and the two #6722 guards that
asserted the impossible shape. Every claim the round-2 comment, architecture-doc
section and _Log.md entry made about "own vs propagated" is corrected.

Round 3 also fixed a real coverage hole round 2 introduced: it called
egress_zone_id's Some(0) short-circuit "redundant rather than load-bearing".
It is load-bearing, and the guard that should have caught its removal had been
modelling an impossible shape too (an unzoned physical parent carrying a zoned
VLAN unit — the builder emits that parent zoned). Re-pointed at the producible
shape, it reds on the lone mutation again.

The defect

An IPsec secure tunnel (st0, an xfrmi) is ARPHRD_NONE, so
forwarding_build::populate_egress never builds an EgressInterface for it —
its src_mac gate is unsatisfiable for such a device. The to-zone of a
forwarding decision was read from state.egress alone, so a correctly-zoned
tunnel resolved to zone id 0, the reserved "unknown zone" sentinel that
evaluate_policy_result_l3_aware deliberately refuses to match ANY exact,
wildcard or junos-global rule against. Every LAN→tunnel packet was adjudicated
as (lan, 0), no operator-authored permit could apply, and the drop was
attributed to the implicit default policy — pointing every diagnostic an
operator would reach for at the wrong place.

The tunnel is correctly zoned in ifindex_to_zone_id, which the INGRESS
half of the same zone pair already reads. The egress half simply did not consult
it.

Direction taken, and why the other was rejected

The issue named two candidates. This takes (2), fix the read
ForwardingState::egress_zone_id falls back to ifindex_to_zone_id when the
interface has no egress row — and makes that helper the single egress-zone
resolver, so policy adjudication, the #3651 per-zone traffic counter, and the
filter-log egress-zone field cannot disagree.

(1), admit the interface to state.egress, was rejected on correctness, not
just on blast radius.
An EgressInterface carries src_mac + bind_ifindex:
it asserts an Ethernet frame can be built for the interface and handed to an
AF_XDP bind target. That is false for a link-layer-less xfrmi, and
session_glue::populate_egress_resolution acts on it — it would set
resolution.src_mac = Some([0; 6]) and tx_ifindex = <xfrmi> where today it
correctly leaves src_mac = None and the packet reaches the kernel through the
slow-path reinject. It would also change what ~30 other state.egress consumers
see (MSS clamp, interface-SNAT source selection, ICMP/PTB reply generation,
zone_to_rgs, WireGuard, fabric, HA owner-RG), none of which this defect
requires.

Scope of the fallback

It fires only when egress has no row at all. A row that exists carrying
zone_id == 0 stays 0, so for every ifindex that HAS an egress row the resolved
to-zone is bit-identical to before.

That Some(0) short-circuit is load-bearing, not defensive. populate_egress
is last-write-wins across snapshot rows, and a non-VLAN unit 0 collapses onto its
base netdev — so a zoned trunk with a declared-but-unzoned unit 0 (ge-0/0/9
zoned lan, ge-0/0/9.0 in no zone, both MAC-ful, both ifindex 90) ends up with
egress[90].zone_id == 0 while ifindex_to_zone_id[90] == lan. Removing the
short-circuit changes the adjudicated to-zone of every such interface;
unzoned_interface_with_egress_row_stays_zone_zero_6713 builds exactly that
shape and reds on the lone mutation (M-A below).

What a shared ifindex resolves to (#6722)

Several logical units can share one ifindex, and ifindex_to_zone_id is a
per-NETDEV map. A MAC-less unit that shares a base ifindex with a zoned sibling
adjudicates under that zone.
Zone only st0.1 and the Go builder still emits
the st0 BASE row carrying vpnb (buildInterfaceZoneMap writes out[base]
for a unit-suffixed zone reference), and st0.0 shares the base's ifindex — so
transit out st0.0 is (lan, vpnb), and a from-zone lan to-zone vpnb permit
applies to it.

That is stated as a behaviour rather than defended as a guarantee, and it is
defensible because it is the value the ingress half has always used for that
same ifindex: scoping only the egress half to a narrower map would make one
ifindex answer vpnb inbound and the 0 sentinel outbound, and 0 matches no rule
at all — #6713 again for that config. Junos zones logical UNITS, so st0.0 and
st0.1 sharing a zone is a genuine parity gap; it needs per-unit identity end to
end (the unit-0 ifindex collapse included) and is filed separately rather than
papered over at this one read.

New Go guard pkg/dataplane/userspace/zone_propagation_6722_test.go pins the two
cross-boundary facts the userspace-dp fixtures encode — a unit-suffixed zone
reference zones the base row, and a non-VLAN unit 0 shares the base ifindex — so
a Rust fixture cannot drift back to a snapshot the Go builder cannot emit.

Per-zone traffic counters change attribution without a code change

The three #3651 record_zone_traffic call sites
(flow_cache_hit.rs, poll_descriptor/mod.rs, disposition.rs) are
pre-existing egress_zone_id callers that this PR changes the behaviour of
without touching a line: a MAC-less tunnel's bytes are now attributed to its zone
instead of zone 0. Reverting any of the three reds nothing in the suite (they are
counters, not forwarding). Noted so a show security zone-traffic-style delta on
upgrade is not mistaken for a regression.

Measured — #6713 stays fixed at round 3

Re-run at the round-3 head through the real chain: snapshot rows measured from
buildInterfaceZoneMap + buildInterfaceSnapshots (both zone-ref spellings
emit identical rows: st0 and st0.0 zoned vpn, one ifindex 42, MAC-less) →
real build_forwarding_state → real FIB → real policy evaluator.
2 spellings × 3 next-hops × 2 destinations × 3 policy shapes = 36 cells:

MATRIX6713 SUMMARY permitted_dropped=0/12 control_denied=24/24

Every permitted cell resolves from=1 to=7 (lanvpn) with policy_id=0, an
operator rule — not DEFAULT_POLICY_SENTINEL_ID. All 24 control cells
(permit scoped to a different pair; operator deny) still deny. 0 of 12
dropped.

The round-1 measurement below is retained for the master-vs-head delta.

Measured (round 1)

Real chain: real Go snapshot (buildSnapshotWithSchedulerStateAndNATCounters,
only buildLinkSnapshot stubbed to a faithful MAC-less xfrmi) → real
build_forwarding_state → real FIB → real policy evaluator. Config: LAN
ge-0/0/0.0 (zone trust) → st0.0 (10.5.5.1/30, zone vpn),
security ipsec vpn v1 bind-interface <spelling>, static route
192.168.99.0/24 next-hop <nh>, default policy deny.

Bare bind-interface st0, the spelling broken on master, 3 next-hops × 2
destinations × 2 permit styles = 12 cells:

next-hop dst permit style master this PR
10.5.5.2 routed zone-pair (trust,0) Deny pid=4294967295, DROPPED (trust,vpn) Permit pid=0, DELIVERED
10.5.5.2 onlink zone-pair (trust,0) Deny, DROPPED (trust,vpn) Permit, DELIVERED
10.5.5.2 routed global (trust,0) Deny, DROPPED (trust,vpn) Permit, DELIVERED
10.5.5.2 onlink global (trust,0) Deny, DROPPED (trust,vpn) Permit, DELIVERED
st0.0 routed zone-pair NoRoute → reinject, delivered unpoliced unchanged
st0.0 onlink zone-pair (trust,0) Deny, DROPPED (trust,vpn) Permit, DELIVERED
st0.0 routed global NoRoute, delivered unpoliced unchanged
st0.0 onlink global (trust,0) Deny, DROPPED (trust,vpn) Permit, DELIVERED
st0 routed zone-pair NoRoute, delivered unpoliced unchanged
st0 onlink zone-pair (trust,0) Deny, DROPPED (trust,vpn) Permit, DELIVERED
st0 routed global NoRoute, delivered unpoliced unchanged
st0 onlink global (trust,0) Deny, DROPPED (trust,vpn) Permit, DELIVERED

Master 8/12 dropped → 0/12 dropped. Exactly the 8 the issue reported.

The dotted bind-interface st0.0 spelling was measured under two kernel models,
because on master today it does not reach policy at all:

state.egress.contains_key(&42) remains false in all 72 measured cells at
both revisions — nothing on the TX path moved.

Fail-closed, proved explicitly

A third policy shape was measured alongside the two permit styles: the only
permit in the config scoped to a DIFFERENT zone pair (vpn → lan). Under it the
tunnel is still denied with policy_id=4294967295 at both master and this
PR, now for the real pair (trust, vpn) instead of the zone-0 accident. The
nopermit column is 8/12 dropped at master and 8/12 dropped here — unchanged.

Tests, each proven to fire

Round-3 battery. Each mutation applied ALONE against a sha256-verified pristine
baseline, with cargo build --release --bins --tests asserted rc 0 before
the test run so a build break cannot be misread as a red. Rust rows run
-- --test-threads=1.

# mutation build RED
M-A widen the egress branch to fire on Some(0) (delete the short-circuit) rc 0 1unzoned_interface_with_egress_row_stays_zone_zero_6713
M-B delete the fallback entirely (undo #6713) rc 0 7 — 5 of the 6 real-builder guards + both hand-built log-site tests; the 6th is a negative control, see below
M-C filter_log_egress_zone_id body → egress-only rc 0 1filter_log_egress_zone_id_reports_a_macless_tunnels_zone_6713
M-D forward_request.rs's own call → egress-only rc 0 1build_live_forward_request_logs_a_macless_egress_zone_6713
M-E restore round-2's own-zone scoping VERBATIM rc 0 2only the two hand-built fixtures
M-F Go: drop out[base] = zoneName in buildInterfaceZoneMap rc 0 3 — both new #6722 Go guards + the pre-existing TestHostInboundVlanUnit0KeepsBaseAddress_5699
M-G option-(a) semantics: an own-zone map that actually excludes an inherited base zone rc 0 3macless_unit_on_a_shared_ifindex_resolves_one_zone_both_directions_6722 + the same two hand-built fixtures

M-A is the round-2 regression closed. That guard had stopped firing on the
lone Some(0) mutation because it modelled a snapshot the builder never emits;
re-pointed at the producible zoned-trunk/unzoned-unit-0 shape it reds again on
the lone mutation.

M-B's 7th row is a negative control, not padding. Six tests reach the
resolver through the real builder; M-B reds five of them. The sixth,
unzoned_interface_with_egress_row_stays_zone_zero_6713, correctly stays
green
— it asserts that an ifindex whose egress row carries zone_id == 0
stays 0, and deleting the fallback cannot disturb that. A guard that red here
would be asserting the wrong thing. Read the row as 5-of-6 plus a control that
held, not as "7".

M-C and M-D do not red each other's test, so the two call sites remain
independently bound — forward_request.rs makes its own egress_zone_id call
and does not route through filter_log_egress_zone_id.

M-E and M-G together are the round — read them as a pair

Either one alone gives the wrong impression. M-E restores round 2's code
verbatim and reds only the two tests that hand-build a ForwardingState and
therefore no longer populate the map that code reads — a fixture artifact. All
six tests that go through the real build_forwarding_state from a
ConfigSnapshot stayed green: round 2's fix changed nothing on any
producible snapshot. On its own that reads as "round 2 was pointless, so revert
it."

M-G is why the replacement is a guard and not just a revert. Simulating the
option-(a) direction — an own-zone map that genuinely excludes a base row's
inherited zone — reds the coherence test while the other five real-builder tests
stay green. That test is the only thing in the suite that would catch a future
re-scoping of the egress half, which is precisely the change rounds 1 and 2 kept
reaching for. Deleting the inert map without adding it would leave the next
author free to reintroduce the same mistake against a green suite.

M-F, stated without overclaiming: the pre-existing #5699 test also depends
on the out[base] write and reds for its own unrelated reason, so the new Go
guards are not the only thing holding that Go behaviour. What they add is
pinning the two specific facts the Rust reasoning rests on — the base row
arrives zoned, and unit 0 shares the base ifindex — which #5699 does not assert.

Which tests are fixture artifacts, and why

Two tests hand-build a ForwardingState rather than going through
build_forwarding_statebuild_live_forward_request_logs_a_macless_egress_zone_6713
and filter_log_egress_zone_id_reports_a_macless_tunnels_zone_6713. Under any
mutation that changes which map the fallback reads (M-E, M-G), they red
because their hand-built state does not populate the newly-read map — not
because behaviour on a real snapshot changed. Recorded explicitly so this is not
re-litigated: the load-bearing signal in those rows is the six tests that DO go
through the real builder, and under M-E all six stayed green.

Suites

Owed

A cluster smoke is owed — this is a dataplane forwarding change. No
cluster/incus run was performed here; the frame-level reinject-vs-recycle step
is established by reading the two poll_descriptor arms
(mod.rs:4241/:4290 deny-and-recycle; permit falls through to the shared
epilogue's is_slow_path_eligible reinject at :5137) rather than by running
the poll loop.

Correction to a landed commit message (9c6cddc70)

9c6cddc70 ("userspace-dp: require a parent ROW before exempting a RETH
member") is on this branch and its body says two universals — the SCOPE
comment's "the other ways two rows share an ifindex are all genuine
independent observers and still vote", and the architecture doc's "the
exemption reaches nothing else" — were "restored as guarantees rather than
qualified into accuracy."

That was false when written. The v3 self-parent hole
(set interfaces st0 gigether-options redundant-parent st0) made a member of
the unit-0-collapse class exemptable, which is precisely one of the "other
ways" the first universal claims to cover: st0.0 matched its own co-resident
st0 BASE row, exempted itself, and the ledger resolved vpnb for an ifindex
whose own netdev row is unzoned. Measured end to end at dcd031d58:
ledger[42]=Some(32521), egress_zone_id=32521, action=Permit.

Both universals hold at the current head, for a different reason than that
commit gives. The paragraph that stood here named rethProjectionNetdevs and
its three-part netdev-SET rule; that spelling is retired and the symbol no
longer exists. The current predicate is rethProjectionMembers
(pkg/dataplane/userspace/interfaces.go): a row is marked only when it
declares a redundant-parent, that parent is a different CONFIGURED
interface, and snapshotLinuxName resolves the parent's base row onto the
same netdev as this row's — the aliasing function asked directly, rather than
a reconstruction of its answer. The unit-0 collapse, interface-level tunnels
and a recycled ifindex satisfy none of that, so they all still vote.

Recorded here rather than fixed in place: a landed commit message cannot be
edited without a rebase, and this branch is not being rebased.

Round 7 — the case split is FOUR-way, and two rows were reachable

Rounds 4-6 reduced rethProjectionMembers' comparison to two branches ("X is
the member RethToPhysical picked" and "a slash-vs-dash canonicalization
collision") and described that reduction as exhaustive. It is not.
snapshotLinuxName is LinuxIfName(ResolveReth(x)) for a reth*-prefixed
name and LinuxIfName(x) otherwise, and it is applied to the PARENT and to
the CANDIDATE, so each side takes either arm independently. Writing L for
LinuxIfName, R for ResolveReth:

# parent candidate equality actually tested before round 7
1 reth non-reth L(R(P)) = L(X) NON-EMPTY — the designed case; branch A covers it
2 non-reth non-reth L(P) = L(X) EMPTY on strict (rejected by #5832); non-empty lenient — branch B, cell K
3 reth reth L(R(P)) = L(R(X)) NON-EMPTY on strict — covered by neither branch
4 non-reth reth L(P) = L(R(X)) NON-EMPTY on strict — covered by neither branch

Measured at 195fcad51 driving the real CompileConfig and the real
buildInterfaceSnapshots, with origin/master (edefb7570) run as the
control on the same configs. Observed values, not expectations:

config verdict P X S(P) S(X) mark
reth1 gigether-options redundant-parent reth0 strict ACCEPTED reth0 reth1 "reth1" "reth1" true
two-cycle: ge-0/0/1 redundant-parent reth1 + reth1 redundant-parent ge-0/0/1 strict ACCEPTED reth1 / ge-0/0/1 ge-0/0/1 / reth1 "ge-0-0-1" both "ge-0-0-1" both true on BOTH rows
reth1 redundant-parent ge-0/0/1 (no cycle) strict ACCEPTED ge-0/0/1 reth1 "ge-0-0-1" "reth1" false

Row 3 makes RethToPhysical[reth0] = reth1, so reth1 — the L3 owner — is
marked a PROJECTION of reth0, and reth0's own rows land on the netdev name
reth1 that no NIC ever carries. Row 4's two-cycle marks every row on the
one ifindex, i.e. declares that the ifindex has no independent observer at all;
the zone survives only because the zoned row's mark is inert under the Rust
gate's zone.is_empty(). In both, an ifindex that answered the 0 sentinel on
master resolves a zone instead. Master accepts the same three configs and marks
nothing (no reth_projection field), so rows 3 and 4 are a delta this PR
introduces.

(Corrected in round 8 — this paragraph originally said master held the ifindex
"AMBIGUOUS". Right number, wrong mechanism: master has no agreement ledger.
populate_egress inserts one egress entry per snapshot row keyed by ifindex,
so the LAST row wins, and egress_zone_id reads that map and answers 0 when the
last row on the ifindex is unzoned. The ledger this PR adds is what makes 0 the
principled answer to DISAGREEMENT rather than an artifact of row order.)

The third row above marks nothing but is not clean either:
ResolveKernelIfName (pkg/config/types.go) reads RethToPhysical UNGATED
for a dotted ref, so ge-0/0/1.0 DISPLAYS as reth1 while the dataplane binds
ge-0-0-1. Measured on both this branch and master — a pre-existing resolver
split, not a regression, closed here as a side effect.

The fix is the commit gate, not a fourth conjunct

validateRethMemberStrict gains a clause rejecting any reth* interface that
declares a gigether-options redundant-parent: a reth OWNS the L3 identity of
a redundant pair and is never a member port of another interface. It sits after
the self-parent clause (so that message is unchanged) and tests
strings.HasPrefix(name, "reth") — the identical test snapshotLinuxName uses
to decide whether to resolve, so the two cannot drift.

Rows 3 and 4 are then empty as a property of the code, not a failed search:
rethProjectionMembers only ever considers a candidate that declares a
redundant-parent, so once no reth* name may declare one, the candidate side
is unconditionally LinuxIfName(name) and neither row is representable. Only
after that is the two-branch reading of the predicate true.

Deliberately NOT done: the stronger clause "a redundant-parent must NAME a
reth* interface" would also close row 2, but row 2 is already closed by #5832
and cell K exists to guard that cross-gate dependency — a second gate rejecting
K's config would leave K green with #5832 relaxed, retiring the only fixture
that binds it.

Cells and mutation proof

L (TestRethNamingARedundantParentIsRejected_6722) — the three shapes above
are commit rejections, the tolerant path still ADMITS each with a warning
(#1960 no-brick), and a control asserts the ordinary bondless-RETH membership
on the SAME two interface names still compiles and is still marked.
M (TestRethNamingARedundantParentMarksTheRethOnTheLenientPath_6722) —
records what the tolerant path does with rows 3 and 4 and states the bound.
(The bound as first stated here — "the silenced row carries no logical units
and no zone" — is false on both halves and was corrected in round 8; see below.)

mutation result
neuter the new reth clause reds exactly L1/L2/L3; H, I and K stay green
neuter the SELF clause still reds H1 (non-reth, no-unit) — the new clause has not taken over the fixture that binds it

Prose corrected everywhere the superseded framing appeared: the
rethProjectionMembers doc comment (now carrying the four-row table), the
validateRethMemberStrict doc comment (four shapes, not three), the Rust
ledger comment in forwarding_build/interfaces.rs, the test-file header, and
docs/userspace-dataplane-architecture.md (its "exactly two branches"
paragraph replaced by the measured table).

Gates

go build ./..., go vet ./..., go test ./... — clean (62 packages ok,
rc 0). cargo test --release --bins --tests in userspace-dp — 4419 passed,
0 failed. make audit-check — up to date. Merged origin/master (never
rebased); _Log.md was the only conflict, union-resolved with both sides
verified as pure insertions, predicted 1589 headings / 3005 entries and got
exactly that, and every line of both parents re-verified as an in-order
subsequence of the result.

One transient pkg/api failure
(TestRetiredLegNeverGainsARotatedCredential_5561, arriving from the #6645
merge) appeared in a single full-tree run and did not reproduce in 8
subsequent runs, isolated or whole-package; this branch touches no file under
pkg/api/. Flagged as a master-side flake candidate, not a regression here.

Round 8 — four claim/test items folded (2baa6095b)

Hostile review at e17de05f1: MERGE-NEEDS-MINOR, zero blocking, and the
four-case answer itself verified with no fifth case. Four items folded. No
runtime behaviour changes on any accepted config — the single production edit is
a commit-check error MESSAGE.

F1 — the stated tolerant-path bound was false on BOTH halves. Three sites:
the rethProjectionMembers doc comment, cell M's doc block, and the round-7
_Log.md entry, all saying the marked reth "carries no units (the gate's unit
clause covers it) and no zone".

The parenthetical is the load-bearing part and it is wrong: on the TOLERANT path
the unit clause is downgraded to a warning exactly like the reth clause, so it
covers nothing there. Measured with the real CompileConfigLenient and the real
buildInterfaceSnapshots:

config marks rows emitted
reth1 redundant-parent reth0 + reth1 unit 0 family inet address 10.0.61.1/24 {reth1} reth1 (marked, zone "") and reth1.0
the same plus security-zone dmz interfaces reth1 {reth1} reth1 marked AND zoned dmz
the 2-cycle (cell M's own sub-case) {ge-0/0/1, reth1} ge-0/0/1 marked AND zoned lan

So "no units" and "no zone" are both false, and cell M contradicted itself two
lines apart — it asserted the marked row carries no zone and then explained that
"a zoned reth still votes".

The bound is not a property of what the marked row carries. It is two structural
facts, both bound by assertions:

  1. A withheld vote is always an EMPTY one. The Rust gate is
    reth_projection && zone.is_empty(), so a marked row that names a zone still
    votes. Withholding can never discard a zone the operator wrote — it can only
    let the ifindex resolve a zone another row on it named, or leave it with no
    contributing row and answer the 0 sentinel. Bound Rust-side with disjoint
    reds (forcing the flag false reds only the unzoned cell; dropping the
    zone.is_empty() conjunct reds only the zoned cell).
  2. UNIT rows are never marked. buildInterfaceSnapshots stamps
    RethProjection: false on every unit row unconditionally, so the mark can
    never silence an independently addressed L3 interface. (The second half of
    this as first written — "so its unzoned units still hold the shared ifindex
    ambiguous" — is false for a VLAN unit and was corrected in round 9; see
    below.)

F2 — cell M's "no units" loop was non-distinguishing. It looped over the
snapshot asserting no reth1.* row exists — a property of its own fixture,
which declares no units, so no production edit could red it — under a comment
claiming the opposite ("The bound, asserted rather than asserted-about") and
justified by F1's false parenthetical. Replaced with a sub-case whose marked
reth does carry a unit, asserting the base row is marked and the unit row is
not. Measured: stamping the unit row from the projection map instead of the
constant false now reds cell M (and cell F); the old loop stayed green under
that same mutation.

F3 — the validator message asserted a consequence one of its own shapes does
not have.
It said unconditionally that the builder "then marks %q as a
PROJECTION of %q and withholds its egress-zone vote". False for
reth1 gigether-options redundant-parent ge-0/0/1 — cell L's own
reth-names-a-physical sub-case — which marks nothing (S(reth1)="reth1"
vs S(ge-0/0/1)="ge-0-0-1"), as that cell's comment already said. An operator
hitting the non-cycling shape was told a consequence their config does not have.
The message now splits the three cases: a reth parent lands the parent's rows on
a name no NIC carries and marks the reth; the two-name cycle marks BOTH rows on
the shared device; the non-cycling case marks nothing but splits the resolvers.

F5 (nit) — master's mechanism. Corrected in the round-7 section above, in
cell L's block and in the architecture doc. Master has no agreement ledger; it
reaches 0 by row-sourced last-write-wins. The Rust provenance comment on this
branch already stated this correctly, so it was an internal inconsistency
between the commit/docs and the code comment.

Round-7 commit message correction. 3f99ba49e conceded "four pre-existing
failures in pkg/dataplane/userspace" as the unix sun_path 108-byte limit.
Under a short TMPDIR there are no failures at all — the package is ok and
the full go test ./... exits 0. They were an artifact of the long GOTMPDIR
that run used, not a property of the tree, and the message should not have
conceded them. Recorded in _Log.md rather than fixed in place, since a landed
commit message cannot be edited without a rebase and this branch is not being
rebased.

Gates at 2baa6095b. go build ./..., go vet ./..., go test ./...
clean, 62 packages ok, rc 0, short TMPDIR. cargo test --release --bins --tests
in userspace-dp — 4419 passed, 0 failed. gofmt clean on the three Go files.
Mutation: the unit-row stamp reds the new cell-M sub-case; neutering the reth
clause still reds exactly L1/L2/L3, so the F3 message rewrite kept the rejection
fragment those cells match on.

Round 9 — two claim defects, three more found by sweeping (c9b020695)

Hostile review at 2baa6095b: two measured claim defects, non-blocking, and
the F2 replacement + _Log.md handling independently confirmed clean. No runtime
behaviour change, no .rs file touched; the one production edit is a
commit-check error MESSAGE.

Item 1 — bound (2)'s second sentence was false; the replacement is STRONGER

Fact (1) — a withheld vote is always an EMPTY one — is sound and unchanged.
Fact (2)'s inference was not. "A grandfathered reth carrying units keeps voting
through them, so its unzoned units still hold the shared ifindex ambiguous" holds
only for a non-VLAN unit 0, which collapses onto the base netdev.
snapshotLinuxName sends a VLAN unit to L(R(name)).<vlan> — a different
netdev. Measured, real lenient compiler + real builder:

config ifindex 31 ifindex 32 resolves
reth1 unit 100 vlan-id 100 [reth0 "lan", reth1 "" MARKED] [reth1.100 ""] lan
reth1 unit 0 [reth0 "lan", reth1 "" MARKED, reth1.0 ""] 0

The replacement bound is stronger, not weaker: a row-3 mark is RUNTIME-INERT.
A row-3 mark needs S(parent) == S(name) with both reth*. S(name) is the
marked reth's own name unless something declares it as a redundant parent — and
if something does, R(parent) is a member of parent while R(name) is a
member of name, two different interfaces, so the equality would need a
canonicalization collision, which is #5832's shape rather than this one. So the
marked netdev is the literal string rethN; a reth is not a kernel device,
buildLinkSnapshot answers ifindex 0, and both populate_interfaces (:55) and
populate_egress (:492) skip ifindex <= 0. The row never reaches the ledger.
Row 4 is not inert — its netdev is a real physical member's — and is bounded
by fact (1).

Item 2 — the reth-parent branch, and the CYCLE branch nobody asked about

The reth-parent branch asserted unconditionally that the parent's rows land on
rethN and that the builder marks the reth. Measured false when the parent
already has a real physical member:

ge-0/0/2 redundant-parent reth0
reth1    redundant-parent reth0
  -> RethToPhysical[reth0] = ge-0/0/2 ; marks = {ge-0/0/2} ; reth1 NOT marked

Sweep, not reported. The predicate behind that finding is "an unconditional
sentence inside a multi-branch message", so all three branches were checked
rather than the one named. The CYCLE branch is conditional too — a two-name cycle
whose reth has a lower-named third member:

ge-0/0/1 redundant-parent reth1 ; reth1 redundant-parent ge-0/0/1 ; ge-0/0/0 redundant-parent reth1
  -> RethToPhysical[reth1] = ge-0/0/0 ; marks = {ge-0/0/0} ; NEITHER cycle row marked

(The first attempt at this used ge-0/0/3, which loses the lexicographic tie and
reproduces the bare-cycle result — the sweep only bites with a name that wins.)

The message now asserts only what is unconditional — the line enters the reth
into RethToPhysical's scoring against the parent's real physical ports — and
states each consequence as a possibility, with an inline note recording why so a
later edit does not re-introduce an outcome claim.

Second sweep, on item 1's predicate, found the same over-general inference at
three more sites, all now carrying the unit-0 qualifier: cell M's doc block, the
tail of validateRethMemberStrict's own doc comment, and fact 5 of the test-file
header together with the matching paragraph in
docs/userspace-dataplane-architecture.md. Cell F's sentence is left as written —
its config is a non-VLAN unit 0, so it is true as scoped.

Binding

New cell-M sub-case reth-carrying-a-vlan-unit: base row marked, unit row
exempt, and the unit row on a different ifindex with LinuxName reth1.100.

mutation result
stamp the unit row from the projection map reds both cell-M sub-cases (and cell F)
collapse the VLAN unit onto the base netdev reds the new ifindex + LinuxName assertions
neuter the reth clause still reds exactly L1/L2/L3; H, I, K green — the message rewrite kept the matched fragment

Gates at c9b020695

go build ./..., go vet ./..., go test ./... — clean, 62 packages ok, rc 0,
short TMPDIR. cargo test --release --bins --tests — 4419 passed, 0 failed
(unchanged, and no .rs file was touched this round). gofmt clean on the three
Go files.

🤖 Generated with Claude Code

https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi

Paul Saab and others added 2 commits August 1, 2026 15:54
An IPsec secure tunnel (`st0`, an xfrmi) is `ARPHRD_NONE`, so
`forwarding_build::populate_egress` never builds an `EgressInterface`
for it: its `src_mac` gate is unsatisfiable for such a device.
`hardware_addr` is empty (netlink reports `hw_len=0`),
`mac_by_ifindex[bind_ifindex]` is absent because the parent is itself a
MAC-less xfrmi, and `iface.tunnel` means a Junos
`tunnel { source destination }` stanza that `st0` does not have.

The to-zone of a forwarding decision was read from `state.egress`
alone, so a correctly-zoned tunnel resolved to zone id 0 — the reserved
"unknown zone" sentinel that `evaluate_policy_result_l3_aware`
deliberately refuses to match ANY exact, wildcard or `junos-global` rule
against. Every LAN->tunnel packet was therefore adjudicated as
`(lan, 0)`: no operator-authored permit could apply — not
`from-zone lan to-zone vpn permit`, not `global ... permit` — the packet
fell to the implicit default policy, and under the default deny it was
dropped before reinjection. The tunnel IS correctly zoned in
`ifindex_to_zone_id` (which the INGRESS half of the same zone pair
already reads); the egress half simply did not consult it.

The fix is at the read, not at `populate_egress`.
`ForwardingState::egress_zone_id` now falls back to the authoritative
`ifindex_to_zone_id` when the interface has no `egress` row, and it
becomes the single egress-zone resolver: the zone-pair resolver (both
the production u16 form and its test-only String twin), the #3651
per-zone traffic counter, and the filter-log egress-zone field all route
through it, so the adjudicated zone and the logged/counted zone cannot
disagree.

Admitting the tunnel INTO `state.egress` — the other candidate
direction — was rejected. An `EgressInterface` carries `src_mac` and
`bind_ifindex`: it asserts that an Ethernet frame can be built for the
interface and handed to an AF_XDP bind target. That is false for a
link-layer-less xfrmi, and `session_glue::populate_egress_resolution`
would act on it, setting `resolution.src_mac = Some([0; 6])` and
`tx_ifindex = <xfrmi>` where today it correctly leaves `src_mac = None`.
It also changes what roughly thirty other `state.egress` consumers see —
MSS clamping, interface SNAT source selection, ICMP/PTB reply
generation, `zone_to_rgs`, WireGuard, fabric — none of which this defect
requires. Fixing the read touches only the zone value.

The fallback is scoped to the absent-row case on purpose. A row that
exists carrying `zone_id == 0` stays 0: `ifindex_to_zone_id` also holds
the zone PROPAGATED from a child unit onto its physical parent, and
inheriting that onto an interface the operator deliberately left unzoned
would widen the adjudicated zone pair for ordinary VLAN trunks. For
every ifindex that has an egress row the resolved to-zone is unchanged.

Validation. Measured end to end through the real chain — real Go
snapshot (`buildSnapshotWithSchedulerStateAndNATCounters` with only
`buildLinkSnapshot` stubbed to a faithful MAC-less xfrmi) -> real
`build_forwarding_state` -> real FIB -> real policy evaluator — over
2 bind spellings x 3 next-hop shapes x 2 destinations x 3 policy shapes.
With an explicit `from-zone trust to-zone vpn permit` (or a `global`
permit) and default-deny, master drops 8 of the bare `bind-interface
st0` spelling's 12 permitted cells with `policy_id=4294967295`
(the default-policy sentinel); at this change all 12 are Permit
`policy_id=0` and reinject. The zone pair moves from `(trust, 0)` to
`(trust, vpn)` in every MissingNeighbor cell. The no-matching-permit
control still denies at both revisions, and `state.egress` still has no
row for the tunnel — nothing on the TX path moved.

Four regression tests, each proven to fire by mutation with a clean
build first (rc 0): removing the fallback reds three of them on the
to-zone (left 0, right 7); redirecting the fallback to the
`junos-global` sentinel reds the same three (left 65535); widening the
fallback to also fire on `Some(0)` reds the scoping guard (left 1,
right 0) while leaving the other three green.

A cluster smoke is owed: this is a dataplane forwarding change.

Issue: #6713

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
…wn zone

The #6713 fallback resolved the to-zone of a row-less interface through
`ifindex_to_zone_id`, which is not a pure "this interface's zone" map:
`populate_interfaces` also propagates a zoned child unit's zone onto
`parent_ifindex` when the parent has no entry of its own. The scope claim
shipped alongside it -- an existing row carrying `zone_id == 0` stays 0,
so an unzoned interface cannot inherit a propagated zone -- held for only
half the domain. A MAC-FUL parent HAS a row carrying 0, so the fallback
never fires and the claim is true. A MAC-LESS parent has NO row, so the
propagated zone WAS inherited, which is exactly the widening the claim
said it prevented, on precisely the interface class #6713 newly routed
through the fallback.

The reachable shape is two secure tunnels on one `st0` -- `bind-interface
st0` plus `bind-interface st0.1`, both legal spellings per
`pkg/config/xfrmi.go` -- with unit 0 addressed and routed but deliberately
left in no security zone. Driven through the real `build_forwarding_state`
-> real FIB -> real policy evaluator, the unzoned unit resolved its
sibling's zone and the operator's `from-zone lan to-zone vpnb permit`
MATCHED, with `policy_id = 0`, a real rule index rather than
`DEFAULT_POLICY_SENTINEL_ID`. The disposition is `MissingNeighbor`, where
a permit is `is_slow_path_eligible` and is reinjected to the kernel while
a deny breaks to `RecycleAndContinue` -- so this forwarded transit that
the previous release denied, out a different IPsec SA than the operator
authorised. The ingress half is not symmetrically exposed, because an
interface with an empty zone is never an AF_XDP ingress bind target, so
#6713 made the widening reachable for the first time.

Fix at the source of truth rather than at the read: `populate_interfaces`
now records the own-zone value a second time in a new
`ForwardingState::ifindex_own_zone_id`, written immediately BEFORE the
parent propagation, and `egress_zone_id`'s fallback reads that map. This
shape was chosen over a propagated-flag or a `(u16, bool)` value type
because it records only ground truth: it needs no remove-on-own-insert
semantics, so it does not depend on snapshot iteration order, and it
touches no existing reader -- the ingress half still wants the propagated
value (#921/#3618). Both branches of `egress_zone_id` now answer the same
question, since `populate_egress` likewise derives
`EgressInterface.zone_id` from the interface's own `iface.zone`.

Two further items from the same review. `tunnel.rs`'s local-origin tunnel
TX path still open-coded the `egress`-only read, contradicting the "single
egress-zone resolver" invariant; it is swept through `egress_zone_id`,
with no runtime difference today because GRE and WireGuard both carry a
`tunnel` stanza and so always have a row -- the comment records why, since
no test can bind a state the snapshot builder cannot produce. And the two
#6713 call sites shipped with no test at all: reverting either
`filter_log_egress_zone_id` or `forward_request`'s own independent
`egress_zone_id` call left the entire suite green, because every existing
filter-log assertion uses a MAC-ful interface where both reads agree.
They are separate call sites, not one helper, and each now has its own
binding.

Validation. Parent-RED at b73679a with only the test additions applied:
`cargo build --release --bins` and `--bins --tests` both rc 0, then
exactly one failure, an assertion (`left: 7, right: 0`) rather than a
build break, with all five #6713 guards green -- so the red is real and
precisely scoped. Mutations, each with build rc 0: pointing the fallback
back at `ifindex_to_zone_id` reds exactly 2 of 4244 tests, the two new
#6722 guards, and nothing else; reverting `filter_log_egress_zone_id`
reds exactly 1; reverting `forward_request`'s call reds exactly 1, and
neither reds the other's test, confirming they needed separate bindings;
reverting the `tunnel.rs` sweep leaves the suite green, the honest result
for a state that is unreachable today. The preserved
`unzoned_interface_with_egress_row_stays_zone_zero_6713` no longer reds on
the lone "fire on Some(0)" mutation -- the own-zone scoping makes that
harmless -- but still reds on the full widening; that is recorded in the
guard's own comment so its greenness is not misread as lost coverage.
Gates: cargo test --release --bins --tests -- --test-threads=1 rc 0
(4242 passed, 2 ignored, plus 60 + 8 + 22 + 31), go build, go vet, and
go test ./... all rc 0 across 59 packages.

The architecture doc's scope paragraph asserted the false claim; it is
rewritten, and the NPTv6, fabric zone-stamp, per-zone half-open-window
and default-permit-upgrade consequences of resolving a real to-zone are
now named there rather than left for a bisect to rediscover.

Advances #6713.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
psaab pushed a commit that referenced this pull request Aug 2, 2026
Folds four MAJORs from a hostile review of ebe3707..f754bee. The
fifth (a MAC-less xfrmi egress resolving to zone 0) is a pre-existing
defect tracked as #6713 and fixed by open PR #6722, which owns the two
Rust files it lives in; it is not touched here, and the documentation
claims that silently depended on it being fixed are corrected instead.

junos-host denies were scoped to the wrong kernel device (FAIL-OPEN).
This one the branch created. junosHostLinuxName resolves the iifname
scope for `to-zone junos-host ... then deny` nft rules and its doc
claimed to mirror snapshotLinuxName exactly, but it still performed the
generic unit-zero collapse. Before this branch both sides collapsed
`st0.0` to `st0` and agreed on a wrong name; fixing only the snapshot
made them disagree on a right one, so with `bind-interface st0.0` the
renderer emitted `iifname st0` while the decrypted plaintext arrives on
`st0.0`. The deny could never match — a security guard rendered unable
to fire, which `show` still reports as configured.

The fix is not to add a fourth copy of the rule. Config.
SecureTunnelUnitNetdev is now the single resolver, and
ResolveKernelIfName, snapshotLinuxName and junosHostLinuxName all call
it. That rule in particular cannot be re-derived per caller: the netdev
comes from the AUTHORED bind-interface, and junosHostLinuxName does not
otherwise read the IPsec config at all.

The secure-tunnel predicate admitted names that cannot be xfrmis.
XFRMIfNameAndID bounds the index to [0, 65536) — the if_id is
`stIndex<<16 | unit+1`, so a larger or negative index has no room —
while IsSecureTunnelIfName ran a bare Atoi with no bounds. So `st-3` and
`st65536` classified as secure tunnels. Interface names are wildcard-
authorable with no `st` reservation, making `st65536` an ordinary data
interface, and the new exclusion therefore removed a live interface from
the ingress-adjudication map, the AF_XDP binding plan and the RSS
allowlist: a traffic outage. Both now share one unexported
secureTunnelIndex, so a classifier can no longer admit what the
constructor rejects. The Rust mirror is_secure_tunnel_ifname takes the
identical bound.

The collision fallback contradicted the routing fail-closed contract.
Two distinct bind-interface strings deriving one if_id made
SecureTunnelNetdevForRef return the lexicographically smallest name for
determinism. pkg/routing/xfrm.go deletes BOTH colliding devices from its
desired set, so neither exists on the box; naming one is not
deterministic-and-correct but deterministically wrong, and it attaches
forwarding state to a device the reconciler has guaranteed is absent. It
now returns ("", false). Two VPNs authoring the SAME string remain one
device, not a collision, and still resolve.

TestSecureTunnelNetdevForRefIsDeterministicUnderCollision required the
old behaviour, so it was rewritten. It was changed because it pinned a
defect, not because it was inconvenient: the property it asserted
(determinism) was real but was the wrong property to hold.

Resolver divergence is scoped deliberately. The three resolvers this
rule belongs to are unified above. Four pre-existing divergences in live
code — reached through CompileUserspaceShim -> CompileConfig ->
compileZones, not retired-eBPF-only — were verified firsthand and filed
rather than half-fixed: #6728, #6729, #6730, #6731. The search scope is
stated in userspace-dp/src/server/README.md so the claim is no broader
than the sweep, including the sites deliberately not audited.

Six documentation claims were verified against the code and corrected
rather than softened. The xfrmi does not enter the egress map:
populate_egress needs a MAC, a parent's MAC or the tunnel flag, and an
ARPHRD_NONE secure-tunnel unit carries none of the three. A matching
permit does not preserve delivery today, because
evaluate_policy_result_l3_aware wraps its whole walk — exact, wildcard
AND junos-global — in `if from_id != 0 && to_id != 0`; that behaviour
depends on #6722 landing, and the README and both code comments now say
so. An any/any policy does not match zone zero, by the same guard. A
bare `next-hop st0.0` still does not resolve. And a deployment with no
bound VPN is not unchanged: the merge base collapsed an unbound unit
zero to `st0` while head returns `st0.0`, and the exclusion keys on the
interface NAME, not on whether a VPN is configured.

Advances #5619.

Validation: go build and go vet clean; full Go suite green (59 packages,
zero failures) under a fresh GOCACHE with TMPDIR=/tmp, with each new
test name confirmed present in -v output. cargo build and the Rust suite
green for the mirrored predicate.

Every behavioural fix was proven by mutating the fix out and confirming
build+vet stayed CLEAN while the specific guard went RED on a real
assertion. Two of the six mutations sit at the EDGE of the claim rather
than its centre: an off-by-one bound (`>` for `>=`) keeps st65535 green
while st65536 reds, and an over-strict collision test reds only the
same-bind-interface case. One mutation moved the SHARED resolver so both
planes agreed on the wrong name — the parity test stayed GREEN and only
the new absolute test went RED, which is why an absolute assertion was
added rather than trusting parity.
Paul Saab and others added 5 commits August 1, 2026 21:33
…findex zone

Round 2 of #6722 added `ForwardingState::ifindex_own_zone_id` to keep the #6713
egress-zone fallback from handing an "unzoned" `st0.0` the zone of its zoned
sibling `st0.1`. That map could not have the effect it claimed. It is built from
`InterfaceSnapshot.zone`, which is `zoneByInterface[name]`, and the Go builder
has already propagated by then: `buildInterfaceZoneMap`
(pkg/dataplane/userspace/zones.go) writes `out[base]` for a unit-suffixed zone
reference, and `snapshotLinuxName` collapses a non-VLAN unit 0 onto the base
netdev. So zoning `st0.1` puts `vpnb` on the BASE row, on the very ifindex unit
0 forwards out of, before Rust sees the snapshot.

Measured with the real builders on the fixture's own config (`buildLinkSnapshot`
is a package var so this is testable):

    zoneByInterface = map[ge-0/0/1:lan ge-0/0/1.0:lan st0:vpnb st0.1:vpnb]
    snap name="st0"   ifindex=42 parent=0  zone="vpnb"
    snap name="st0.0" ifindex=42 parent=42 zone=""
    snap name="st0.1" ifindex=43 parent=42 zone="vpnb"

The round-2 Rust fixture gave the base row no zone -- a snapshot the builder
never emits. The general case follows: a Rust child->parent propagation can only
add an entry for `parent_ifindex(U)`, which is the base row's ifindex, and that
row's own `Zone` is non-empty in both zone-ref spellings. The two maps were
therefore identical on every producible snapshot, and the round-2 fix was a
runtime no-op.

Resolution: accept the propagated behaviour instead of carrying an
own-vs-inherited flag across the Go->Rust boundary. The ingress half already
resolves that ifindex to `vpnb` (`ifindex_to_zone_id` is the from-zone source),
so scoping only the egress half would make one ifindex answer two zones by
direction -- and the narrower answer is the 0 sentinel, which matches no exact,
wildcard or `junos-global` rule, i.e. #6713 again for that config. Junos zones
logical UNITS, so `st0.0` and `st0.1` sharing a zone is a real parity gap; it
needs per-unit identity end to end (the unit-0 ifindex collapse included) and is
filed separately rather than papered over at this one read.

Deleted `ifindex_own_zone_id`, its insert and the two #6722 guards; the fallback
reads `ifindex_to_zone_id` again. The Rust child->parent propagation is kept as
a helper-boundary backstop with a comment saying it is unreachable for a
Go-produced snapshot.

Also corrected a real coverage hole round 2 introduced. It called
`egress_zone_id`'s `Some(0)` short-circuit "redundant rather than load-bearing".
It is load-bearing: `populate_egress` is last-write-wins across snapshot rows,
so a zoned trunk with a declared-but-unzoned unit 0 (`ge-0/0/9` zoned `lan`,
`ge-0/0/9.0` in no zone, both MAC-ful, both ifindex 90) gets
`egress[90].zone_id == 0` while `ifindex_to_zone_id[90] == lan`, and the
short-circuit is the only thing holding the to-zone at 0. The guard meant to
catch its removal had been modelling an unzoned physical parent carrying a zoned
VLAN unit, which the builder never emits -- the parent arrives zoned -- so it
was green on an impossible shape and had stopped binding. Re-pointed at the
producible shape.

New Go guard pkg/dataplane/userspace/zone_propagation_6722_test.go pins the two
cross-boundary facts the userspace-dp fixtures encode, so a Rust fixture cannot
drift back to a snapshot the Go builder cannot emit. Fixture drift is what cost
rounds 1 and 2.

Validation. Full suite green under a timeout wrapper (so a wedge would surface
as rc 124 rather than silence): `timeout 3600 cargo test --release --bins --tests
-- --test-threads=1` rc 0, 4241 + 60/8/22/31/1/2 passed, 2 ignored, and the eight
#6713/#6722 tests confirmed as RUN rather than skipped; go build/vet/test rc 0.

Seven mutations, each applied alone against a sha256-verified baseline with the
build asserted rc 0 first so a build break cannot be misread as a red. Six tests
reach the resolver through the real `build_forwarding_state`; two hand-build a
`ForwardingState`, and several rows turn on that distinction:

  M-A widen the egress branch to fire on `Some(0)`      -> 1 RED (the re-pointed
      #6713 scoping guard; this is the round-2 hole closed)
  M-B delete the fallback (undo #6713)                  -> 7 RED
  M-C filter_log_egress_zone_id -> egress-only          -> 1 RED
  M-D forward_request's own call -> egress-only         -> 1 RED (M-C and M-D do
      not red each other's test: the two call sites stay independently bound)
  M-E restore round-2's own-zone scoping VERBATIM       -> 2 RED, and ONLY the
      two hand-built fixtures, which no longer populate the map that code reads.
      All six real-builder tests stayed GREEN: round 2 changed nothing on any
      producible snapshot -- measured, not argued.
  M-F Go: drop out[base] = zoneName                     -> 3 RED (both new Go
      guards plus the pre-existing #5699 test, which depends on the same write)
  M-G option-(a) scoping that really excludes inherited -> 3 RED, including the
      coherence test; the other five real-builder tests stay green, so that test
      is the only thing that would catch a future re-scoping of the egress half

#6713 is not re-broken: the plain `bind-interface st0` matrix (2 zone-ref
spellings x 3 next-hops x 2 destinations x 3 policy shapes = 36 cells) run
through the real snapshot -> build_forwarding_state -> FIB -> policy chain shows
permitted_dropped=0/12, every permitted cell resolving from=lan to=vpn under an
operator rule id, and control_denied=24/24.

Advances #6713.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
…index

Round 3 justified resolving a MAC-less egress interface's to-zone from
`ifindex_to_zone_id` by arguing "directional coherence" -- that ingress
and egress must answer the same zone for one ifindex. That argument is
wrong. Several logical units collapse onto one netdev
(`snapshotLinuxName` maps a non-VLAN unit 0 back onto its base), so an
ifindex is not a unit identity, and `ifindex_to_zone_id` holds the LAST
zoned row on it plus the child->parent propagation. Reading it as the
egress answer hands an interface a zone the operator never configured
there -- and a NONZERO to-zone is exactly what makes an operator's
permit MATCH, so that direction is fail-OPEN.

Three producible shapes do it:

  1. Zone only `st0.1`; `buildInterfaceZoneMap` still stamps the `st0`
     BASE row with that zone, and `st0.0` -- which the operator left in
     NO zone -- shares the base's ifindex.
  2. Two units in DIFFERENT zones on one `st0` with unit 0 unzoned. The
     `out[base]` write is first-write-wins over SORTED zone names, so
     unit 0's ifindex carries the alphabetically-first sibling's zone.
  3. StableZoneID quarantine. `quarantineCollidingZones` blanks `Zone`
     on a colliding zone's interfaces AFTER `buildInterfaceSnapshots`
     ran, precisely so they fail CLOSED. The base then arrives unzoned
     beside a surviving zoned child, the Rust child->parent propagation
     re-zones the parent ifindex, and reading it would hand the
     quarantine's deliberate default-deny back the survivor's zone.

New `ForwardingState::ifindex_unambiguous_zone_id`, built in
`populate_interfaces` over ALL snapshot rows -- zoned and unzoned alike,
because an unzoned row's "no zone" is an opinion that must be able to
conflict with a zoned sibling's. An ifindex lands in it only when EVERY
row sharing it named the same nonzero zone; disagreement leaves it
absent and `egress_zone_id` resolves the 0 sentinel, the pre-#6713
answer, against which no rule matches and the default policy decides.

The two directions now deliberately disagree for an ambiguous ifindex.
The justification is DIRECTIONAL, not a claim that the ingress surface
is unreachable: only in shape 3 is every row unzoned, which
`interfaces.go`'s `if iface.Zone == "" { continue }` keeps off the AF_XDP
bind list entirely. In shapes 1 and 2 the base row is zoned and ingress
really does answer that zone. What differs is that ingress answering
wide is pre-existing (#921/#3618) and untouched here, while egress
answering wide is NEW. Whether the ingress half should be narrowed the
same way is left explicitly unsettled.

#6713 is untouched: in every #6713 shape the rows on the tunnel's
ifindex agree, so the ifindex stays in the unambiguous map.

Two claims are labelled as design rationale rather than guards, because
mutating them leaves the suite green: keeping the propagation out of the
agreement ledger, and the `zone_id != 0` skip at the flush (a stored
`Some(0)` and an absent key both resolve 0 through `.unwrap_or(0)`).
`row_zone_id != 0` is exactly the pre-existing `!zone.is_empty()`
condition -- `zone_name_to_id_from_snapshot` skips `zone.id == 0`, so a
name that resolves resolves nonzero.

Also corrects `tunnel.rs`: only gre/ip6gre reach the local-origin loop,
not WireGuard -- `endpoint_attachment_valid` parks every other mode.

Advances #6722.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
… not

The round-3 coherence test
`macless_unit_on_a_shared_ifindex_resolves_one_zone_both_directions_6722`
did not bind anything. Its fixture already gave the `st0` base row
`vpnb`, so restoring the map the PR had deleted left it GREEN -- it
could only ever agree with the resolver by coincidence. It is deleted,
not re-pointed, and replaced by four tests that adjudicate through the
real FIB and the real policy evaluator:

  - unzoned_macless_unit_does_not_inherit_a_zoned_siblings_zone_6722
  - divergently_zoned_sibling_units_do_not_pick_a_zone_6722
  - quarantine_unzoned_base_does_not_inherit_the_surviving_childs_zone_6722
  - reused_ifindex_across_two_zoned_interfaces_resolves_no_zone_6722

Each asserts to-zone 0 AND names the specific wrong nonzero value the
fail-open would produce, so none can pass by resolving some other zone;
each first asserts that `ifindex_to_zone_id` -- the map the egress half
must NOT read -- carries a real nonzero zone for that ifindex, so
"to-zone is 0" is never indistinguishable from an empty state; and each
requires the verdict to come from `DEFAULT_POLICY_SENTINEL_ID` rather
than the sibling's permit.

`unanimously_zoned_shared_ifindex_still_reaches_policy_6713` is the
scope control: `set security zones security-zone vpnb interfaces st0`
fans out to every unit, both rows on the ifindex agree, and the fallback
must still resolve. That is why the gate keys on DISAGREEMENT and not on
"more than one row shares this ifindex".

All five snapshot fixtures move to `afxdp::test_fixtures` and are driven
through the real `build_forwarding_state`. Round 3 kept hand-built
`ForwardingState`s in `poll_descriptor::filter` and
`frame::tests_ports_live_forward` and claimed independently maintained
fixtures could not drift; they can and did -- both populated
`ifindex_to_zone_id` alone, so they encoded a map layout instead of a
snapshot and went red on a builder change that was correct. Both now
build from the shared fixture.

Both log sites gained an ambiguous-ifindex case. `forward_request.rs`
calls the resolver independently of `filter_log_egress_zone_id`, so the
gate has to be proven at each: a log field naming `vpnb` for transit the
firewall denied under the default policy sends an operator hunting a
`lan->vpnb` rule that never ran.

Red-on-revert, `cargo test --release -- 6722 6713` (14 tests, baseline
14/14 green):

  - fallback re-pointed at `ifindex_to_zone_id`: 6 RED (all four
    forwarding tests + both log sites), e.g. "left: 7 right: 0". All
    EIGHT #6713 tests stayed GREEN, so the gate is scoped to ambiguous
    ifindexes and does not re-break #6713.
  - agreement ledger fed only by ZONED rows: 4 RED (the two sibling
    shapes + both log sites); the quarantine and reused-ifindex tests
    correctly stay green, their ambiguity having a different source. The
    two ledger properties are independently bound.

Advances #6722.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
…napshot

The Go guard called only `buildInterfaceZoneMap` + `buildInterfaceSnapshots`,
never `buildSnapshot` -- which is exactly why it could assert that the
Rust child->parent zone propagation is unreachable for a Go-produced
snapshot. `quarantineCollidingZones` runs AFTER `buildInterfaceSnapshots`
(builder.go) and blanks `Zone` on every row bound to a colliding zone, so
a base whose zone lost a StableZoneID collision arrives UNZONED beside a
surviving zoned child, and the propagation fires. The claim is corrected
and `TestQuarantineUnzonesTheBaseRow_6722` emits the counterexample.

Zone names are picked for their SORT order, which drives two independent
mechanisms: z174/z214 collide and the later-sorting name (z214) is the
one quarantined; and `buildInterfaceZoneMap`'s `out[base]` write is
first-write-wins over sorted names, and z214 sorts before zzzz, so the
doomed zone is the one that lands on the `st0` base row. The test pins
that pre-quarantine placement before running the pass, so an empty Zone
afterwards is a scrub rather than the failure default.

It also pins that the STRICT compiler REJECTS the collision (#3075), so
the lenient boot / HA-sync / pre-#3075-persisted path is the only way a
colliding snapshot can reach the quarantine at all.

Two failure-default assertions fixed. `unit0.Ifindex != base.Ifindex`
passes as `0 == 0` when the `buildLinkSnapshot` stub resolves neither
row, so both cases now pin the primed values (42/43, 90/91). And the
empty-HardwareAddr assertions equal what the stub returns for an
unresolvable link, so case A gained a positive control: the LAN row must
carry its MAC through the SAME stub, or the empty xfrmi MACs prove
nothing.

Advances #6722.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
The architecture doc still described the round-3 design, and three of its
claims are now false: that both zone-resolution halves read the same
authoritative source, that the deleted own-zone map was inert, and that
independently maintained test fixtures cannot drift. It also asserted the
Rust child->parent propagation is unreachable for a Go-produced snapshot,
which the StableZoneID quarantine disproves.

The section now documents `ifindex_unambiguous_zone_id`, the three
producible shapes an ifindex-wide answer would mis-adjudicate, and the
deliberate ingress/egress asymmetry -- including the part that is NOT
true: the ingress surface is only unreachable in the quarantine shape,
where every row is unzoned. In the sibling and divergent shapes the base
row is zoned and ingress really does answer that zone, so the asymmetry
rests on direction (ingress-wide is pre-existing, egress-wide is new and
turns a deny into a permit), not on reachability. The downstream-consumer
list is scoped to an unambiguous ifindex, since an ambiguous one still
resolves 0 and none of those consumers fire for it.

Advances #6722.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
Paul Saab and others added 2 commits August 5, 2026 08:27
The gate's claim is "an ifindex resolves a to-zone only when EVERY
snapshot row on it named the same nonzero zone", but every fixture in
this issue put at most TWO rows on a shared ifindex. That drives the
agreement fold `Vacant -> Occupied-same` and `Vacant -> Occupied-different`
and never exercises a third row arriving AFTER a conflict was recorded,
so the guard fired while being scoped narrower than its claim.

The gap admits a producible fail-open. Rewriting the fold's comparison
against an unwrapped zone id instead of the whole `Option` --

    if let Some(existing) = *slot.get() {
        if existing != row_zone_id { slot.insert(None); }
    } else {
        slot.insert(Some(row_zone_id));     // re-arms after a conflict
    }

-- is green on all fourteen existing tests, and on `st0` with units
0/1/2 where unit 0 is unzoned it resolves `vpnb` for an ifindex whose
rows arrive `vpnb` -> none -> `vpnb`. That is the original #6722
fail-open, reachable again through an ordinary three-unit config.

`conflict_then_agreement_snapshot_6722` is the first fixture here with
three rows on one ifindex, and it asserts the ORDER before adjudicating
so the absorbing-conflict path is exercised rather than assumed.
`None` is absorbing because the `!=` is written against the whole
`Option`; the comment now says so and names the mutation.

Red-on-revert: the mutation above yields exactly 1 RED --
`a_conflicted_ifindex_is_not_rearmed_by_a_later_agreeing_row_6722`,
"ifindex 42 is shared by rows that disagree about its zone, so it must
not appear in the unambiguous map at all" -- with the other 14 green,
which is precisely the point. Restored (with `touch`, so cargo could not
re-run the mutated binary) and 15/15 green.

Advances #6722.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
Applying the guard-D lens to the rest of the change. A two-element
fixture is structurally incapable of testing an absorbing state -- it
can only ENTER the state, never attempt to re-arm it -- and "EVERY row
on that ifindex agrees" is a universal that a pair cannot exercise. Two
more gaps found, one of them with a mutation invisible to the entire
suite.

Zero sentinel arriving FIRST. Every prior fixture ran
nonzero-then-sentinel on the shared ifindex; nothing tested the mirror
order. A fold that reads a recorded `Some(0)` as "no opinion yet" and
lets a later nonzero row upgrade it is GREEN on all sixteen other tests
and resolves `vpnb` for an ifindex two unrelated interfaces share.
Order-dependence is a defect even where it happens to yield the
operator's answer: `buildInterfaceSnapshots` row order is not a contract
the Rust side may lean on. Pinned by
`a_zero_sentinel_row_is_not_upgraded_by_a_later_zoned_row_6722`; the
mutation above yields exactly 1 RED.

Unanimity over THREE rows rather than a pair, so the positive direction
is tested as a universal too. NEGATIVE RESULT, recorded as such: no
mutation was found that this test catches and the two-row unanimous case
misses. It is coverage of the universal, not a proven guard, and is
described that way rather than credited as one.

Quarantine with more than one blanked row was ALREADY covered -- the
fixture blanks both the base and the unit-0 row, and the Go test asserts
both. No change.

FIXTURE PROVENANCE, and a defect in the guard-D test from the previous
commit. It put a third row on the shared ifindex by giving `st0.2`
`linux_name = "st0"`. `snapshotLinuxName` collapses only a non-VLAN unit
ZERO onto the base, and `TunnelNameMap` gives unit N>0 its own device
(`gr-0-0-0u1`), so that row is one `buildInterfaceSnapshots` never emits
-- the evidence-free-fixture class this PR exists to fight, with a
docstring claiming a config that does not produce it. A third row on one
ifindex is producible ONLY by ifindex RECYCLING within one snapshot, the
mechanism `reused_ifindex_snapshot_6722` already rests on. All three new
fixtures are rebuilt on base + unit-0 collapse plus a recycled `st1.0`,
the provenance is written into each fixture, and guard D was re-proven
on the corrected shape.

Last absolute phrasing corrected. `egress_zone_id`'s "the logged/counted
zone can NEVER disagree" is a completeness claim over CALL SITES that
nothing enforces. It now says "do not disagree", states that this holds
BY ENUMERATION rather than by construction, names which three sites are
pinned by tests, and warns that the zone-accounting readers are not --
so a new direct `state.egress` read there would not be caught here.

Advances #6722.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
@psaab

psaab commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Independent gate at 8839f9fdf: MERGE-NEEDS-MAJOR — the fail-open this PR closes is still reachable

The agreement ledger itself is correct and absorbing. The reviewer walked every transition at forwarding_build/interfaces.rs:121-129 and confirmed no path writes a zone after None, and that Some(0) is never upgraded. The three-row and sentinel-first cases added this round are genuinely bound.

What blocks is that the resolver does not use it.

B1 — egress_zone_id consults state.egress first and only falls back to the ledger

userspace-dp/src/afxdp/types/forwarding.rs:630-639:

self.egress.get(&egress_ifindex).map(|iface| iface.zone_id)
    .or_else(|| self.ifindex_unambiguous_zone_id...)

and the second builder pass writes unconditionally at forwarding_build/interfaces.rs:420-427:

state.egress.insert(iface.ifindex, EgressInterface { ... zone_id, ... })

So a later zoned row re-arms an already-conflicted index through state.egress, while the ledger correctly holds None. The ledger arithmetic is right; the end-to-end property is not absorbing.

B2 — the producibility claim is false, and it is what concealed B1

test_fixtures.rs:1398-1408 states a third row cannot come from another unit, because TunnelNameMap gives unit N>0 its own device. Interface-level tunnels do the opposite. pkg/config/types.go:310-335 — units without their own tunnel stanza under an interface-level tunnel share the interface device and are assigned baseName. snapshotLinuxName consumes that at pkg/dataplane/userspace/interfaces.go:428-433, and pkg/dataplane/userspace/tunnels_test.go:130-135 already demonstrates wg0, wg0.0 and wg0.1 sharing Linux name wg0 and ifindex 42.

The defect, from a stable non-racy configuration

set interfaces wg0 tunnel mode wireguard
set interfaces wg0 unit 0 family inet address 10.5.5.1/30
set interfaces wg0 unit 1 family inet address 10.6.6.1/30
set security zones security-zone vpnb interfaces wg0.1

Emitted rows, all on one ifindex: wg0vpnb (because buildInterfaceZoneMap writes out[base]), wg0.0 → unzoned, wg0.1vpnb. All carry Tunnel=true, so populate_egress admits them via iface.tunnel.then_some([0; 6]) at interfaces.rs:396-401. The last row leaves egress[ifindex].zone_id = vpnb; egress_zone_id returns vpnb without reading the ledger; and a lan → vpnb permit matches traffic routed through the deliberately-unzoned wg0.0.

That is the original #6722 fail-open, still live.

Why the suite is green on it

Every ambiguity test requires the ifindex to have no egress rowforwarding/tests.rs:5431-5434. The fixtures are MAC-less secure tunnels, which have none. The interface-level tunnel shape has one, so the entire ambiguity suite passes on a configuration that reproduces the bug. All four guard mutations (A, B, D, E) target ledger arithmetic; none would catch B1.

B3 — several fixtures' exact shapes are not producible

reused_ifindex_snapshot_6722, conflict_then_agreement_snapshot_6722, sentinel_before_zoned_row_snapshot_6722 and unanimous_three_row_ifindex_snapshot_6722 each omit a base row the builder necessarily emits (interfaces.go:219-236, :290-324) or reference a device that does not exist. Their [Z,0,Z], [0,0,Z] and [Z,Z,Z] projections are all directly producible by multi-unit WireGuard without recycling — so the fix is to rebuild them on that mechanism, which also gives them the egress rows the current fixtures lack.

The reviewer's summary is the line to keep: the test projections exercise ledger arithmetic, but they do not prove the real egress-row interaction.

Confirmed sound, so it is not lost

The numerous-but-unanimous direction is semantically correct and must keep resolving: set security zones security-zone vpnb interfaces wg0 with units 0 and 1 yields three agreeing rows and must return vpnb. Do not over-tighten into rejecting it.

Paul Saab and others added 7 commits August 5, 2026 09:38
…dger

The ledger was correct; the resolver did not depend on it.
`egress_zone_id` reads `state.egress` FIRST and only falls back to
`ifindex_unambiguous_zone_id`, and `populate_egress` wrote
`egress[ifindex].zone_id` from the ROW's own zone, last-write-wins per
ifindex. On an ifindex that several differently-zoned rows share, the
LAST row therefore decided the to-zone and the ledger was never
consulted. The four mutations proven in the previous rounds all mutate
ledger arithmetic, so none of them could catch this.

Reachable from a stable, non-racy config:

    set interfaces wg0 tunnel mode wireguard
    set interfaces wg0 unit 0 family inet address 10.5.5.1/30
    set interfaces wg0 unit 1 family inet address 10.6.6.1/30
    set security zones security-zone vpnb interfaces wg0.1

`TunnelNameMap` (`pkg/config/types.go`) maps every unit WITHOUT its own
tunnel stanza onto the interface device -- the branch admits WireGuard
despite its empty GRE-style `source` -- so `wg0`, `wg0.0` and `wg0.1`
are one netdev and one ifindex, as `tunnels_test.go` already pins. All
three carry `tunnel = true`, so `populate_egress` admits them through
`iface.tunnel.then_some([0; 6])` and they DO get egress rows.
`buildInterfaceZoneMap` stamps the base row `vpnb`, `wg0.0` is left
unzoned, and the zoned `wg0.1` is emitted last -- so transit routed out
the deliberately-unzoned `wg0.0` matched `lan -> vpnb permit`. That is
the defect this PR exists to close, intact underneath a green suite:
every ambiguity test asserted the ifindex has NO egress row, which is
true of the MAC-less secure tunnels the fixtures used and false here.

`populate_egress` now takes `zone_id` from `ifindex_unambiguous_zone_id`,
so both arms of the resolver derive from one source and cannot disagree.
The alternative -- gating the read inside `egress_zone_id` -- was
rejected: it adds a second map lookup to a per-packet path for no
semantic gain. The #2391 unknown-zone check is retained as an explicit
reject so an unresolvable zone NAME still fails the snapshot closed.

This also makes the `Some(0)` short-circuit correct BY CONSTRUCTION.
`unzoned_interface_with_egress_row_stays_zone_zero_6713` held only
because the unzoned unit-0 row happened to be emitted last; reverse the
order, as the WireGuard shape does, and the zone won instead.

Fixture producibility, the same lesson one layer down. The round-5 claim
that a third row on one ifindex cannot come from another unit cited
`TunnelNameMap`'s per-unit branch (`gr-0-0-0u1`) and missed the
interface-level branch directly above it, which does the opposite. The
recycling-based multi-row fixtures were unproducible for a second reason
too: they omitted base rows `buildInterfaceSnapshots` necessarily emits.
All three are removed -- the WireGuard shapes subsume them exactly,
giving `[Z, 0, Z]`, `[0, 0, Z]` and `[Z, Z, Z]` from real config and
WITH egress rows. `reused_ifindex_snapshot_6722` is kept, two unrelated
interfaces on a recycled index being a distinct shape, with its missing
base rows added.

Red-on-revert: sourcing the egress row's zone from the row again yields
2 RED -- `unzoned_iface_tunnel_unit_does_not_inherit_a_siblings_zone_via_egress_row_6722`
("left: 7 right: 0") and
`iface_tunnel_egress_row_is_not_upgraded_by_a_later_zoned_unit_6722` --
with the other 15 green. Both tests were written and watched RED on the
parent before the fix. Scope control
`unanimous_iface_tunnel_units_still_reach_policy_6722` keeps resolving
`vpnb` and matching the operator's permit, so an ordinary WireGuard
deployment still forwards.

Blast radius: none on the existing suite (4248 passed; the 2 failures
are the #6819 poisoned-mutex flake). The change is observable only where
rows on one ifindex disagree.

Advances #6722.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
…he helper

The comment claimed the helper-level tests "close" the production call
site. They do not drive it. Every assertion called
`filter_log_egress_zone_id` directly, so re-opening the pre-#6713
`state.egress`-only read INSIDE `emit_cached_output_filter_log_tail` --
its only production caller -- left the whole suite green: the helper is
still correct, it just is not the thing being called.

`cached_output_filter_log_reports_the_adjudicated_zone_6722` drives the
real caller and asserts the emitted event. It adjudicates TWO ifindexes,
and the pairing is what makes it bind:

  - the AMBIGUOUS ifindex must log 0 (the #6722 gate), and
  - the MAC-less ZONED tunnel must log its zone (the #6713 fallback).

The second is the discriminator. An ambiguous ifindex alone does NOT
bind the consumer: since the egress row's `zone_id` became
ledger-derived, an ifindex that HAS an egress row gets the same answer
from the helper and from a raw `state.egress` read, so the mutation
stays green. Only an ifindex with NO egress row separates them. The
first version of this test used the ambiguous index alone and was
green under the mutation; that is recorded in the test comment so the
pairing is not "simplified" away later.

Each emission uses a fresh event-stream handle. Reusing one across both
made the second `try_recv` return `Empty` -- the stream is stateful per
handle -- which would have made the assertion depend on stream
internals rather than on the zone.

Two claim corrections, both cases of the artifact being stronger than
the code:

  - The architecture doc said the logged and counted zones "cannot
    disagree" without the enumeration caveat the source comment already
    carried. It now says "do not disagree", states that this holds by
    enumeration rather than by construction, names the four sites tests
    pin, and names the zone-accounting readers that are not pinned.

  - PROVENANCE. The WireGuard ambiguity was already latent in the
    index-keyed `egress` map before this branch: on `origin/master`
    `egress_zone_id` is an `egress`-only read and `populate_egress`
    already took the row's own zone last-write-wins, so that shape
    already adjudicated `vpnb` there. #6713 did not create the defect;
    it added the fallback and routed more consumers through the same
    incomplete resolver. Recorded in the source and the doc so a bisect
    is not misled about what this branch introduced versus inherited.

Red-on-revert: re-opening the `state.egress`-only read inside
`emit_cached_output_filter_log_tail` yields 1 RED -- the new consumer
test, "left: 0 right: 7" -- with BOTH direct-helper tests staying GREEN.
That green is the evidence for the finding.

Advances #6722.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
…est-scope limit

Text only; no logic changes. Three claims in the change were wrong or
stale, two of them in a way that would mislead a future reader.

The architecture doc claimed every ifindex with an egress row resolves a
to-zone "bit-identical to the pre-#6713 read". That is false, and
`[Z, 0, Z]` is the direct counterexample: the old read returned the last
row's `Z`, the ledger returns `0`. Since that change is the POINT of
#6722, the doc now says so rather than denying it -- bit-identical
wherever the rows AGREE, which is every ordinary single-unit interface,
and deliberately different where they disagree. A second instance of the
same false claim, in the downstream-consumer preamble, is corrected too:
"resolves 0" matches pre-#6713 only for an ambiguous ifindex with NO
egress row.

`ifindex_unambiguous_zone_id` was documented "Read only by
`egress_zone_id`". That went stale when `populate_egress` began sourcing
`EgressInterface::zone_id` from it; the doc comment now names both arms
and says why the row is the wrong source.

`forwarding/mod.rs` still described the resolver as falling back to
`ifindex_to_zone_id`. It falls back to `ifindex_unambiguous_zone_id`;
the comment now says which, and why the from-zone map is the wrong
source for a to-zone.

Also records a test-scope limit next to the ledger rather than leaving
it to be discovered: no public test can distinguish an erroneous re-arm
to `Some(0)` from a genuine conflict, because the flush omits both from
the map and `egress_zone_id` ends in `.unwrap_or(0)`, so both resolve to
the same 0. The observable security property -- an ambiguous ifindex
never adjudicates a zone -- is pinned in BOTH resolver arms, but this
internal representation is not exhaustively mutation-tested, and a
mutation turning a conflict into `Some(0)` will not red. The note says
what to do about it: add an accessor before relying on the distinction.

Two further claims flagged against the previous head were already
corrected in `1a7ff02d3` -- the doc's enumeration caveat for the
"cannot disagree" wording, and the filter.rs helper-vs-consumer scope,
which also gained a test driving the production
`emit_cached_output_filter_log_tail`.

Advances #6722.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
`userspace-dp/src/afxdp/poll_descriptor/filter.rs` crossed the 1500-LOC
boundary that puts a file INTO the audit (1603 at head) and was never
added, so `TestHeatmapNotStale` failed. That is a GO test failing because
of a RUST file, which is why it went unnoticed for several rounds: the
Rust gates on this branch are green and there is no reason to run the Go
suite on a Rust-only change.

Regenerated with `bash scripts/refactoring-audit.sh`, not hand-edited.
The script also refreshes within-tier LOC numbers for files this branch
never touched (compiler_system.go, daemon_nft.go, neighbor.rs,
session/mod.rs, compiler_opts.go, compiler_validate_warn.go) and
reorders them accordingly. That drift is what the generator produces —
the gate compares the audited file SET and TIER assignment, not the LOC
figures, which is why master is green while carrying it. A hand-narrowed
edit would disagree with the script and be reverted by the next
regeneration.

`filter.rs` is the only file whose tier changes; it enters at [WATCH].

`userspace-dp/src/afxdp/test_fixtures.rs` also crossed 1500 (995 -> 1548)
and correctly gets NO entry: `AUDIT_SKIP_RE` in
scripts/refactoring-audit-lib.sh excludes `(^|/)test_[^/]*\.rs$`, and
that library's own comment names test_fixtures.rs as one of the files
that pattern subsumed in #6232. Confirmed from the rule rather than
inferred from its absence in the failure output.

Kept as its own commit so the mechanical regeneration is separable from
the logic and documentation changes on this branch.

Advances #6722.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
Two conflicts, resolved by their different natures.

_Log.md is union-resolved: every entry from both sides is retained and
none is rewritten. The file is no longer append-ordered, so line counts
and prefix checks prove nothing about it; the resolution was instead
verified structurally, by confirming each pre-merge side diffs into the
result with add-hunks only -- 4 hunks from this branch, 1 from master,
0 changed-or-deleted on either side.

docs/refactoring-audit-current.txt is a GENERATED artifact and was
regenerated from the merged tree with scripts/refactoring-audit.sh
rather than union-resolved. Unioning a generated file would have
manufactured a heatmap corresponding to no tree that exists: master and
this branch each recorded a snapshot of their own source, and the merged
source is neither. The regenerated file differs from master's by exactly
one line -- a [WATCH] entry for this branch's new
userspace-dp/src/afxdp/poll_descriptor/filter.rs at 1603 lines -- and
the authoritative pkg/refactoraudit drift canary passes against it.

Validation on the merged tree: go build ./... clean; cargo build
--release clean; the full userspace-dp cargo suite green (4269 passed,
0 failed, plus the five auxiliary suites), no panics and no wedge.

Advances #6713.
Two files conflicted. `_Log.md` is union-resolved: both sides' entries are
kept and only the three conflict-marker lines are dropped. Verified after
resolving that all 492 branch-only lines and all 6286 master-only lines are
present, so neither side's history was lost.

`docs/refactoring-audit-current.txt` is a GENERATED artifact and both sides
had regenerated it, so a textual merge would have produced a file matching
neither tree. It is instead regenerated from the merged worktree with
`scripts/refactoring-audit.sh`, and the result is byte-identical to a fresh
generation — which is the only resolution that keeps `make audit-check`
meaningful.

Validation: the anchored conflict-marker sweep over the whole worktree
returns nothing, and the regenerated audit file diffs clean against a
second independent run of the generator.
@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Independent re-gate at 886ad8662: DO-NOT-MERGE — the round-6 fix introduces a fail-CLOSED regression on this project's own reference HA topology

The agreement-ledger design is right, and the round-6 write-side fix is correctly bound — both mutation cells turn RED via assertion, not build break. What blocks is that the fix's premise ("an ifindex whose rows disagree identifies no zone") is applied to a row set that includes a pure device-alias row.

B1 — a bondless-RETH physical member poisons the ledger for its own RETH's ifindex

The PR enumerates two ways rows can share an ifindex: non-VLAN unit-0 collapse, and interface-level tunnels. There is a third, and it is the only one that reaches a shipped topology: ResolveReth collapses a RETH onto its physical member's netdev. snapshotLinuxName returns config.LinuxIfName(cfg.ResolveReth(ifName)) for both the base row and unit 0 (pkg/dataplane/userspace/interfaces.go:437-443), ResolveReth("reth1") returns ge-0/0/1 (pkg/config/types.go:100-110), and buildInterfaceZoneMap never zones a physical member because Junos zones the reth, not the member (pkg/dataplane/userspace/zones.go:58-88).

So the deliberately-unzoned member row and the zoned RETH rows land on one ifindex — and the unzoned row's Some(0) is a dissenting vote. Every row with ifindex > 0 votes; there is no MAC gate and no "is this a routable logical interface" gate ahead of interfaces.rs:122-131.

Measured — the real docs/ha-cluster-userspace.conf through the full buildSnapshot:

ifindex 24: [ge-0/0/1="" reth1="lan" reth1.0="lan"]   <-- DISAGREE
ifindex 25: [ge-0/0/2="" reth0="wan"]                 <-- DISAGREE
DefaultPolicy="deny"

That config is not hypothetical: test/incus/loss-userspace-cluster.env:24 sets CLUSTER_CONF="docs/ha-cluster-userspace.conf" — the cluster every HA smoke test here runs on.

Measured — the same rows through build_forwarding_state, at this head versus with only interfaces.rs:456-460 reverted:

this head :456-460 reverted (master's behaviour)
egress[24].zone_id Some(0) Some(1)
ledger[24] None
ifindex_to_zone_id[24] Some(1) Some(1)
egress_zone_id(24) 0 1
zone pair (7 wan, 0) (7 wan, 1 lan)
verdict Deny, policy_id=4294967295 Permit, policy_id=0

policy.rs:2679 gates the entire rule walk — exact pair, from-any, to-any, both-any, and junos-global — on from_id != 0 && to_id != 0. With to-zone 0 no rule can match and default-policy deny-all drops the packet. Every WAN→LAN, sfmix→LAN and tunnel→LAN transit flow on a bondless-RETH cluster blackholes.

LAN→WAN survives, because egress ifindex 27 carries a single row — which is precisely why a plain iperf3 smoke would come back green. That is the part worth sitting with: the reference smoke for this project would not have caught it.

Why the obvious refutations fail

Seven were attempted and all fail. The member row is not filtered before Rust (builder.go:41 passes the slice untouched; quarantineCollidingZones only blanks zones). It is not MAC-less in production, and even a MAC-less row would still poison the ledger. reth1.0 does not get a synthetic ifindex — interfaces.go:73-81 requires VlanID > 0 and unit 0 has VlanID 0. And it is not pre-existing: master's populate_egress took the row's own zone last-write-wins, rows are emitted in sorted-name order (ge-0/0/1 < reth1 < reth1.0), so the final write was lan. The regression is introduced by ad4f0c113 — the fix for the previous round's finding.

The asymmetry is the tell: ifindex_to_zone_id[24] is still Some(1), so from-zone lan still works while to-zone lan is unreachable. Only the egress half regresses.

And zone 0 does not fall through to a global policy: JUNOS_GLOBAL_ZONE_ID = u16::MAX, distinct from 0, and the global tier lives inside the to_id != 0 gate.

The remediation is bounded

The member row must not cast an independent vote. Smallest correct change is Go-side: stamp an interface with RedundantParent != "" with its RETH's zone — they are literally one kernel netdev, and nothing can egress ge-0/0/1 that is not reth1.0 traffic. The "disagreement" is an artefact of describing one device with three snapshot rows, not an operator ambiguity. That restores unanimity on ifindex 24/25 without weakening the #6722 guard, because wg0.0 and st0.0 remain genuinely distinct logical units and keep voting.

A Rust-side alternative needs a new InterfaceSnapshot field — pkg/dataplane/userspace/protocol.go:244-269 carries RedundancyGroup but not RedundantParent.

The fix IS bound — that part is not in question

mutation result tests
revert interfaces.rs:456-460 to the row's own zone (the whole fix) RED, 8 passed / 2 failed, assertions unzoned_iface_tunnel_unit_does_not_inherit_a_siblings_zone_via_egress_row_6722, iface_tunnel_egress_row_is_not_upgraded_by_a_later_zoned_unit_6722
break the absorbing None RED, 8 passed / 2 failed, assertions unzoned_iface_tunnel_unit_... (ledger-ambiguous precondition), reused_ifindex_across_two_zoned_interfaces_resolves_no_zone_6722

Non-blocking, all falsified-by-B1 or adjacent

Re-dispatching for the fix plus a regression fixture pinning the three-row RETH shape on both sides.

#6722 B1 (ad4f0c1) made the egress row take its zone_id from the
agreement ledger rather than from the row's own zone. The ledger's model
of "how can several rows share one ifindex" covered the non-VLAN unit-0
collapse and interface-level tunnels, but missed a third mechanism --
and it is the only one that reaches a shipped topology.

ResolveReth (pkg/config/types.go) resolves a RETH to its PHYSICAL
MEMBER, and snapshotLinuxName applies it to the reth base row AND its
units, so ge-0/0/1, reth1 and reth1.0 are ONE kernel netdev. Junos zones
the RETH and never the member, so the member's rows arrive UNZONED and
their "no zone" was counted as a dissenting vote. Measured through the
full buildSnapshot on docs/ha-cluster-userspace.conf (node 0 -- the
topology test/incus/loss-userspace-cluster.env points every HA smoke
test at):

    ifindex 24: [ge-0/0/1="" reth1="lan" reth1.0="lan"]   <-- DISAGREE
    ifindex 25: [ge-0/0/2="" reth0="wan"]                 <-- DISAGREE
    DefaultPolicy="deny"

With the ledger ambiguous, egress_zone_id(24) returned 0 instead of 1,
the zone pair became (wan, 0), and policy.rs's `from_id != 0 && to_id
!= 0` gate skipped every tier -- exact, from-any, to-any, both-any and
junos-global -- so default-policy deny-all dropped the packet. Every
WAN->LAN, sfmix->LAN and tunnel->LAN transit flow on a bondless-RETH
cluster blackholed. LAN->WAN survived because its egress ifindex has a
single row, which is exactly why an iperf3 smoke in the usual direction
came back green. The INGRESS half was unaffected throughout
(ifindex_to_zone_id[24] still carried lan); that asymmetry was the tell.

A ledger is only sound if every row voting on an ifindex is an
INDEPENDENT observer of it. A RETH member's row is a PROJECTION of the
RETH's netdev, not an observer -- nothing can egress ge-0/0/1 that is
not reth1.0 traffic. So carry the member relationship on the wire as a
new additive `redundant_parent` field and have populate_interfaces
exempt a row that carries it AND has no zone of its own from voting.

Stamped on the member's base row AND its unit rows: a member's units
alias the matching reth unit too, since a VLAN unit resolves to
LinuxIfName(ResolveReth(base)).<vlan>. Measured -- a member carrying
`unit 0` + `unit 100 vlan-id 100` puts {ge-0/0/1, ge-0/0/1.0, reth1} on
one ifindex and {ge-0/0/1.100, reth1.100} on another, so stamping only
the base row would have left the second pair ambiguous.

The zone.is_empty() half of the gate is load-bearing, not defensive: a
member the operator EXPLICITLY zoned differently from its RETH is a real
statement about a real conflict and must keep failing closed.

Route not taken, and why. Stamping the member with the RETH's zone in
buildInterfaceZoneMap was prototyped and MEASURED to reintroduce #5699.
The bondless-RETH address lives on the member netdev, so with a zone the
member row enters BuildZoneHostInboundViews and the single live address
10.0.61.1 lands in TWO views with DIFFERENT admit sets ([ssh ping] from
reth1.0's per-interface override vs [ssh] from the zone default). The
kernel host-inbound chain matches destination address only, so the
verdict is order-dependent -- the deterministic false-deny the #5699
comment exists to prevent. Its existing guard cannot fire because it
keys on ifc.Units[0] != nil and a RETH member has no units. The full
pkg/dataplane suite passes WITH that defect present.

Alias-mechanism audit, since the general lesson is to enumerate every
projection: the unit-0 collapse and interface-level tunnels are GENUINE
logical units and still vote; fab0 is not an alias at all (measured as
its own netdev/ifindex -- snapshotLinuxName never calls ResolveFab);
bondless-RETH VLAN synthetic ifindexes are unique by construction; and a
recycled ifindex across two unrelated interfaces is two genuinely
distinct observers that must keep voting.

The field is additive both directions -- omitempty on the Go side,
serde(default) on the Rust side, and no deny_unknown_fields anywhere in
userspace-dp/src/protocol -- and the DEGRADED direction is the safe one:
an old helper ignores the key, an old Go binary omits it, and in both
cases the member votes and the ifindex stays ambiguous, i.e. the
fail-CLOSED behaviour. The wire fixture regen adds exactly one key.

Validation: the new binder
unzoned_reth_member_row_does_not_strip_the_reths_egress_zone_6722 reds
at the unmodified PR head 886ad86 with an ASSERTION (left: 0, right:
1), not a build break. Two over-reach controls stay GREEN there and are
each proven to FIRE under their own mutation --
explicitly_zoned_reth_member_still_makes_the_ifindex_ambiguous_6722 reds
when the zone.is_empty() half is dropped, and
reth_exemption_does_not_leak_to_iface_tunnel_units_6722 reds when the
exemption is widened. Both pre-existing #6722 mutation cells still red
after this change (revert the ledger-sourced egress zone_id -> 4 red;
break the absorbing None -> 4 red), so the B1 ledger guard remains
bound. Go row-shape tests bind the producible snapshot -- base stamp,
unit stamp, over-reach, JSON round-trip -- and each reds on its own
revert. cargo test --release rc=0 (4419 passed), go build ./... rc=0,
go test ./pkg/dataplane/... ./pkg/config/... rc=0, gofmt clean on every
touched file.

Docs: corrected three claims this falsified. interfaces.rs's "an
ordinary single-unit interface is unaffected" is true again but now
states WHY (the member casts no vote) rather than being deleted;
types/forwarding.rs now enumerates all THREE ifindex-sharing mechanisms
and names ResolveReth as the one that reaches a shipped topology; the
architecture doc gains the B2 section and states BOTH directions of the
0 sentinel -- fail-CLOSED under deny-all (what the reference cluster
runs) and fail-OPEN under permit-all, where zone 0 skips the operator's
DENY rules too, consistent with the pre-existing #3110 decision to treat
zone 0 as unmatchable rather than as a wildcard.

Advances #6713.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Route B implemented at df19a5743 — and the alias enumeration changed the fix

Branch fix/6722-reth-ledger, one commit on top of the PR head 886ad8662, unrebased. Master 4960e7bee.

Route A was rejected on measured evidence, not preference

My own recommendation — stamp RedundantParent != "" rows with the RETH's zone — reintroduces #5699 verbatim. Measured: one live address 10.0.61.1 lands in two host-inbound views with different admit sets ([ssh ping] on reth1.0, [ssh] on ge-0/0/1), against a kernel chain that matches destination address only. The #5699 guard at zones_host_inbound.go:195 cannot fire because it keys on ifc.Units[0] != nil and a RETH member has no units, and go test ./pkg/dataplane/... passes with the defect present. Fixing a blocking bug by opening a hole in host-inbound admission, then needing a second change to a security surface to close it, is worse than the bug.

Route B instead: a RedundantParent field on InterfaceSnapshot; an unzoned row carrying one casts no ledger vote. Zone is untouched, so host-inbound, NAT interface-mode exclusions, the binding-plan key and observability are bit-identical. It says the correct thing directly — the defect was never "these rows disagree", it is that one of them is not an independent observer.

The enumeration was not ceremonial — it found a half-fix

I required an enumeration of every mechanism that can put two rows on one ifindex. It surfaced a case nobody had: a RETH member can carry units, and a member's VLAN unit resolves to LinuxIfName(ResolveReth(base)).&lt;vlan&gt;, aliasing the RETH's VLAN unit on a second ifindex:

ifindex 24: [ge-0/0/1  ge-0/0/1.0  reth1]
ifindex 30: [ge-0/0/1.100  reth1.100]

Stamping only the base row would have left that pair ambiguous — and the reference config could never have exposed it, because its members carry no units. Bound independently: revert only the unit-row stamp and the Go case reds alone.

Full table: unit-0 collapse and TunnelNameMap are genuine logical units and still vote; ResolveReth base and VLAN units are projections and are exempt; fab0 is not an alias (measured — its own netdev at ifindex 40 against ge-0/0/0 at 23; snapshotLinuxName never calls ResolveFab); a recycled ifindex across two unrelated interfaces is two distinct observers and must vote.

Evidence

RED at the unmodified head as an assertion (forwarding/tests.rs:5940, left: 0 right: 1), re-verified at the final tree. Both controls proven to fire under their own targeted mutations rather than left as decoration — dropping the zone.is_empty() half reds the explicit-zone control alone; widening the exemption reds the WireGuard leak guard. Both pre-existing ledger cells still RED at 4 apiece, so the earlier guard did not go slack. Fixture regen is exactly one key, with back-compat quoted rather than asserted (no deny_unknown_fields anywhere; #[serde(rename, default)]).

Gates, exit codes unpiped: go build rc=0 · go test ./pkg/dataplane/... ./pkg/config/... rc=0 · cargo test --release rc=0, 4419 passed · gofmt -l clean on touched files.

Full re-gate in flight, aimed at the two risks the author named against their own work

Not a delta review — the prior verdict was DO-NOT-MERGE, so the whole change is back in scope. The sharpest probe is one the author flagged as reasoned rather than measured: the quarantine pass (zones_quarantine.go:97) blanks Zone by zone name, and an empty zone is exactly the new exemption's trigger. So a member explicitly zoned into X, its RETH in Y, with only X quarantined, would previously blank to "", vote 0, disagree with Y and fail closed — and now blanks to "" and carries redundant_parent, so it is exempt, casts no vote, and the ledger resolves Y. Quarantine erases the operator statement the explicit-zone control exists to honour. Whether that is a genuine behaviour change, whether it is safe, and whether anything observes it are the questions.

The other two: whether redundant_parent can reach a row that is not a projection (it is a hole in a security guard, so reachability is load-bearing), and whether buildInterfaceSnapshots really is the sole producer of snapshot rows.

@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Full re-gate at df19a5743MERGE-NEEDS-MAJOR, measured in both directions. The hole is in the predicate I chose.

I picked route B and argued it "says the correct thing directly." The direction is right; the predicate is not. redundant_parent is an unvalidated operator string, copied unconditionally onto every row of any interface that mentions it — so !redundant_parent.is_empty() && zone.is_empty() does not mean this row is a projection, it means this row's interface mentioned a redundant-parent. Those differ, and an operator controls the difference.

The reviewer's line is the one to keep: sole-writer is not the same as sound-trigger. My own trace — "written in exactly one place, an unconditional field copy at both emission sites" — is precisely what makes the hole, and I offered it as reassurance.

Measured, through the real compiler and the real builder

There is no validator anywhere requiring the named parent to exist, to be a reth*, or to resolve back to this interface (zero hits for RedundantParent/redundant-parent in compiler_validate_strict*.go / *_warn*.go), and schema_interfaces.go:71 accepts gigether-options under any interface name.

(a) The headline — one config line re-opens #6722, on the exact shape this PR's own control guards.

set interfaces st0 gigether-options redundant-parent reth1
set interfaces st0 unit 0 family inet address 10.5.5.1/30
set interfaces st0 unit 1 family inet address 10.6.6.1/30
set security zones security-zone vpnb interfaces st0.1
set security policies default-policy deny-all

Strict CompileConfig accepts this. RethToPhysical even resolves reth1 → st0.

ifindex_unambiguous_zone_id[42] egress_zone_id(42)
HEAD df19a5743 Some(7) 7 (= vpnb)
exemption reverted None 0 (fail-closed)

st0.0 — a unit the operator deliberately left in no zone — now adjudicates to vpnb, so from-zone lan to-zone vpnb permit matches traffic that previously fell to deny-all. That is bit-for-bit the shape unzoned_macless_unit_does_not_inherit_a_zoned_siblings_zone_6722 exists to hold at 0, and the shape reth_exemption_does_not_leak_to_iface_tunnel_units_6722 claims to guard. The control only holds while redundant_parent happens to be empty, and the operator can set it.

(b) A dangling parent, no tunnel needed. redundant-parent reth1 on ge-0/0/1 with reth1 never defined: ledger goes None → Some(1), egress 0 → dmz. Master answers 0 here, so this commit introduces the flip — not #6713, not master.

(c) Two local members of one RETH — same row shape, same result for the non-projection member.

Refutations attempted and defeated

"Only a physical RETH member can carry it" — refuted by (a): st0 carries it and strict commit accepts. "The RETH's own rows keep it honest" — true only when the reth is defined and RethToPhysical resolves to this member; (a)/(b)/(c) are the three ways that breaks, all commit-accepted. "Some(0) and absent are equivalent so the exemption is a no-op" — true only when the exempt row is alone on its ifindex; all three scenarios have a zoned co-resident. Peer-node member rows were checked and are genuinely harmless.

The fix, and why Rust-side

Encode "this row is a projection of another row's netdev": pre-pass the interface list and exempt a row only if some other row on the same ifindex has name == iface.redundant_parent or name.starts_with(&format!("{}.", iface.redundant_parent)). Three lines, no new wire field. I prefer this to the Go-side narrowing because this function already treats the helper boundary as a fail-closed backstop (#2391/#2409/#2706), so it stays correct against a drifted or hostile snapshot rather than trusting the producer. Plus the (a) over-reach control as a committed fixture.

I am implementing this myself. I directed route B, so its regression is mine to close, and it will be gated by a reviewer who did not write it.

The quarantine probe: reachable, and correctly ruled NOT a regression

I flagged this as the weakest link and asked for it to be measured. It was, and the answer is better than I expected. The construction works exactly as I described — zones_quarantine.go:96-99 blanks the member's zone and thereby manufactures the exemption's own trigger, moving the outcome from 0 to z174. But it restores master rather than exceeding it: buildInterfaceSnapshots emits names sorted, so master's last-write-wins populate_egress already answered z174; the intermediate 0 was the anomaly. Post-fix egress also now agrees with ingress. And the quarantine's own contract is that the colliding zone is dropped as if it never existed, so resolving the surviving RETH statement is coherent.

The residue is that the control test's premise — a deliberately-zoned member is an operator statement — is conditional on that zone not having been quarantined, and nothing pins it. The reverse direction was also checked: quarantine can only turn nonzero into 0 (fail-closed) except through this member exemption. One test with the reasoning written down, so a later reader does not "fix" it back to 0.

Two universal claims are now false and need the qualifier

interfaces.rs:158-162 ("the other ways two rows share an ifindex are all genuine independent observers and still vote") and docs/userspace-dataplane-architecture.md:948 ("reaches nothing else"). st0.0 votes only while st0 carries no gigether-options redundant-parent. The proper fix is B1: make the sentences true rather than qualifying them.

Also docs:954-957 calls the degraded direction "the safe one" while the next bullet correctly says refusing to guess is safe only to the extent the default policy is — under permit-all it is the fail-open direction. And interfaces.rs:135 states "Junos zones the RETH, never the member" as a builder property; zones.go:43-91 has no such rule (measured zoneByInterface[ge-0/0/1] = "z214"). It is operator convention, and the code correctly does not depend on it.

What held up

The B2 diagnosis and the fix's direction are confirmed by measurement against the real docs/ha-cluster-userspace.conf: the row composition, the zones, DefaultPolicy="deny", and the LAN→WAN-survives asymmetry all reproduce. Zone is genuinely untouched — host-inbound, NAT, binding plan, ifindex_to_zone_id and zone_to_rgs bit-identical. fab0 is confirmed not an alias. The three-mechanism enumeration is correct, including the ge-0/0/1.100reth1.100 alias, and the unit-row stamp is measurably load-bearing for it (M2 reds only the unit-row test). buildInterfaceSnapshots is confirmed the sole producer, with #6480 partial-republish inheriting next.Interfaces verbatim. Wire compat holds. All four Go mutation cells and the Rust cell are assertion failures, not build breaks. All 13 Rust 6722 tests and the Go 6722 tests green at HEAD.

The #6722 B2 exemption let an unzoned physical RETH member's row abstain
from the egress-zone agreement ledger, because that row is a projection
of the RETH's own netdev rather than an independent observer of it. The
predicate chosen to express "is a projection" was

    !iface.redundant_parent.is_empty() && iface.zone.is_empty()

and that is not the same claim. `redundant_parent` is an unvalidated
operator string: `schema_interfaces.go` accepts `gigether-options` under
any interface name, and no compiler pass requires the interface it names
to exist, to be a `reth*`, or to resolve back to the row carrying it.
The Go builder then copies the string onto the base row and every unit
row unconditionally. So the gate read "this interface mentioned a
redundant-parent", and the operator controls whether that is true.

Measured, through the real strict compiler and the real builder:

    set interfaces st0 gigether-options redundant-parent reth1
    set interfaces st0 unit 0 family inet address 10.5.5.1/30
    set interfaces st0 unit 1 family inet address 10.6.6.1/30
    set security zones security-zone vpnb interfaces st0.1
    set security policies default-policy deny-all

is ACCEPTED by `CompileConfig`. Under the name-only gate `st0.0` — a
unit the operator deliberately left in no zone — is exempted, casts no
vote, and `egress_zone_id` moves from the fail-closed 0 sentinel to
`vpnb`, so `from-zone lan to-zone vpnb permit` matches traffic that
previously fell to `deny-all`. That is the original #6722 fail-open, in
the exact shape `reth_exemption_does_not_leak_to_iface_tunnel_units_6722`
was written to guard; the control only held while `redundant_parent`
happened to be empty. A dangling `redundant-parent` on a physical
interface produces the same flip, and there master answers 0 — so the
name-only form regressed master rather than merely under-fixing.

Narrow the gate to the invariant that actually makes a projection a
projection: some OTHER row on the same ifindex must be the parent,
either `<parent>` or `<parent>.<unit>`. A pre-pass collects the row
names per ifindex, because a projection's parent row may be emitted
before or after it — the Go builder walks names sorted, so `ge-0/0/1`
precedes `reth1` but `st0` does not, and a backwards-only scan would be
order-dependent. Checked on the Rust side rather than in Go because this
function already treats the helper boundary as a fail-closed backstop
(see #2391, #2409, #2706), so the gate stays sound against a drifted or
hostile snapshot instead of trusting the producer.

This also makes two claims true that the narrow gate had falsified: the
SCOPE comment's "the other ways two rows share an ifindex are all
genuine independent observers and still vote", and the architecture
doc's "the exemption reaches nothing else". Both are restored as
guarantees rather than qualified into accuracy.

Validation: reverting only the parent-row requirement reds exactly one
test — the new control — as an assertion, rc=101 with zero `error[E...]`
lines, 13 passed / 1 failed. Every other 6722 test stays green under
that mutation, including the B2 binder
`unzoned_reth_member_row_does_not_strip_the_reths_egress_zone_6722`, so
the narrowing does not weaken what the exemption fixed and the new
control is the only thing that distinguishes the two gates. Full
`cargo test --release` 4420 passed / 0 failed; `go build ./...` clean;
rustfmt clean within both edited ranges, with no crate-wide `cargo fmt`.
@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

B1 CLOSED at 9c6cddc70 — the gate now requires a parent ROW, not a parent NAME

I directed route B, so its regression was mine to close. The narrowing is three lines of predicate plus a pre-pass, in populate_interfaces:

let row_is_reth_member_projection = !iface.redundant_parent.is_empty()
    && iface.zone.is_empty()
    && names_by_ifindex.get(&iface.ifindex).is_some_and(|names| {
        names.iter().any(|other| {
            *other != iface.name.as_str()
                && (*other == iface.redundant_parent.as_str()
                    || other
                        .strip_prefix(iface.redundant_parent.as_str())
                        .is_some_and(|rest| rest.starts_with('.')))
        })
    });

Why a pre-pass and not a backwards scan. A projection's parent row can be emitted either side of it: the Go builder walks names sorted, so ge-0/0/1 precedes reth1 but st0 does not. A gate that only looked at rows already seen would be order-dependent — correct for the physical-member case and wrong for the tunnel case, which is exactly the case that produced the hole. The pre-pass costs one BTreeMap<i32, Vec<&str>>; rows per ifindex are 2-3, so the inner scan is cheaper than another map.

Why Rust-side. Taken from the reviewer's recommendation and for the reason given: this function already treats the helper boundary as a fail-closed backstop (#2391/#2409/#2706), so the gate stays sound against a drifted or hostile snapshot rather than trusting the producer. The Go-side narrowing would have been correct too, but it would put the invariant on the side that can drift.

*other != iface.name keeps a self-referential redundant-parent (an interface naming itself) from matching its own row.

The control that was missing, and the cell that proves it distinguishes

dangling_redundant_parent_does_not_exempt_a_genuine_observer_6722, built from the measured scenario (a) — st0 / st0.0 / st0.1 with redundant_parent: "reth1" and no reth1 row anywhere. Reverting only the parent-row requirement:

result
control (fix in place) 14 passed / 0 failed
revert the parent-row clause 13 passed / 1 failed, rc=101, 0 error[E…] lines

The single failure is an assertion carrying its own reasoning:

---- dangling_redundant_parent_does_not_exempt_a_genuine_observer_6722 stdout ----
panicked at src/afxdp/forwarding/tests.rs:6060:5:
no row named `reth1` shares this ifindex, so `st0.0` is not a projection of
anything -- it is a genuine unzoned logical unit and must still make the
ifindex ambiguous

Two things that matter more than the red itself:

  • The new control is the ONLY test that distinguishes the two gates. All 13 others stay green under the mutation, so nothing pre-existing could have caught this — which is why the hole shipped past a hostile review, an AGY pass, and my own reasoning.
  • The B2 binder unzoned_reth_member_row_does_not_strip_the_reths_egress_zone_6722 stays GREEN under the mutation, so the narrowing does not weaken what route B fixed. The fix is strictly a narrowing, not a partial revert.

The two universal claims are now true rather than qualified

The reviewer offered the choice of qualifying interfaces.rs:158-162 ("the other ways two rows share an ifindex are all genuine independent observers and still vote") and docs:948 ("reaches nothing else"), or making them true. I took the second. Both sentences stand unchanged; the architecture doc gains a bullet stating the parent-row requirement, why the name alone is not the invariant, and that a name-only gate would have regressed master on the dangling-parent shape rather than merely under-fixing it.

Gates

cargo test --release 4420 passed / 0 failed · go build ./... rc=0 · rustfmt clean inside both edited ranges — the remaining crate diffs sit at :100/:428/:480/:513, outside my ranges (47-65, 185-224) and pre-existing. No crate-wide cargo fmt was run.

Still open from the re-gate, and my reading

N1 (quarantine) — I am adding the test, not changing behaviour. The reviewer measured that it restores master rather than exceeding it, and that the only way quarantine turns 0 into nonzero is through this member exemption. That reasoning holds. What is missing is a test pinning the resolved-Y outcome with the reasoning written down, so a later reader does not "fix" it back to 0. That is next.

N3/N4 (two doc nits) — mechanical, riding with N1: the "degraded direction is the safe one" sentence needs the permit-all qualifier the very next bullet already supplies, and "Junos zones the RETH, never the member" is operator convention, not a builder property (zones.go:43-91 has no such rule — measured zoneByInterface[ge-0/0/1] = "z214").

Head is 9c6cddc70. The re-gate that found B1 should not gate its own fix, so the next hostile leg goes to a reviewer who did not write this.

@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Round 7 at e17de05f1 — the four-case split worked through. Two of the four cases were non-empty on the STRICT path.

My error was larger than I stated when I posted the Codex verdict. I said the two-branch reduction "may still be true, but it needs the four-case split worked through, and case 4 is the one nobody has examined." Measured, rows 3 AND 4 are both reachable under strict CompileConfig, and each one marks a reth* interface — the L3 owner — as a projection of something else.

# parent P candidate X equality tested resolved to
1 reth non-reth L(R(P)) = L(X) NON-EMPTY — the designed case. Branch A.
2 non-reth non-reth L(P) = L(X) EMPTY on strict (#5832 rejects); non-empty lenient. Branch B / cell K.
3 reth reth L(R(P)) = L(R(X)) NON-EMPTY on strict. Covered by NEITHER branch.
4 non-reth reth L(P) = L(R(X)) NON-EMPTY on strict. Covered by NEITHER branch.

Branch A covered row 1, branch B covered row 2. Rows 3 and 4 — the ones where the candidate side takes the ResolveReth arm — were never covered by either. The reduction did not have a gap at case 4; it had a gap at every case where the candidate is a reth name, which is half the table.

Measured, with master as a control on the same configs

  • set interfaces reth1 gigether-options redundant-parent reth0 — strict ACCEPTED. P=reth0, X=reth1, S(P)=S(X)="reth1"mark=true. reth1 is marked a projection of reth0, and reth0's own rows land on a netdev name no NIC carries.
  • Two-cycle ge-0/0/1 redundant-parent reth1 + reth1 redundant-parent ge-0/0/1 — strict ACCEPTED, mark=true on BOTH rows of one ifindex. Every row on that ifindex declares itself a non-observer of it; the zone survives only because the zoned row's mark is inert under the Rust gate's zone.is_empty().
  • reth1 redundant-parent ge-0/0/1 (no cycle) — marks nothing, but ResolveKernelIfName reads RethToPhysical ungated for a dotted ref, so ge-0/0/1.0 displays as reth1 while the dataplane binds ge-0-0-1.

Control: master accepts all three and marks nothing. So the first two are a delta this PR introduces — an ifindex that was ambiguous on master (fail-closed at the 0 sentinel) now resolves a zone. The third is a resolver split master shares: pre-existing, not a regression.

The fix is at the gate, not a fourth conjunct

validateRethMemberStrict gains a clause rejecting any reth* interface that declares gigether-options redundant-parent, placed after the self-parent clause so that message is unchanged, and testing strings.HasPrefix(name, "reth") — the identical test snapshotLinuxName uses, so the two cannot drift.

Why this empties rows 3 and 4 as a property of the code rather than a failed search: rethProjectionMembers only ever considers a candidate that declares a redundant-parent. Once no reth* name may declare one, no candidate it sees is a reth* name, so snapshotLinuxName(name) is unconditionally LinuxIfName(name) and neither row is representable. Only after that is the two-branch reading true. That is the shape I asked for and did not get in five prior rounds — the emptiness now follows from a clause, not from looking and not finding.

Deliberately not widened to "a redundant-parent must name a reth": that would also close row 2, but row 2 is already closed by #5832, and cell K is the only fixture binding that cross-gate dependency — a second gate rejecting K's config would leave K green with #5832 relaxed. Correct call.

Mutation proof

Neutering the new clause reds exactly L1/L2/L3, with H, I and K staying green — no over-reach into the sibling gates. Neutering the self clause still reds H1, so the new clause has not taken over the fixture that binds it. That second control is the one that matters: a new gate that silently subsumes an older gate's coverage leaves the older one vacuous.

Gates and merge

go build/go vet/go test ./... rc 0 (62 packages). cargo test --release --bins --tests 4419 passed / 0 failed. make audit-check up to date.

The _Log.md union proof is worth noting: predicted 1589 ## headings and 3005 - **Timestamp** entries and got exactly that, but the line count came to 80341 against a naive 80342 — because both sides begin their insertion at the same base line with the same blank separator, which git anchored outside the conflict. The arithmetic was not trusted; the structural invariant was checked directly, confirming every line of both parents is still an in-order subsequence of the result. That is the right response to a count that disagrees with a prediction by one.

Superseded framing removed everywhere it appeared — the predicate doc comment, validateRethMemberStrict's doc, the Rust ledger comment, the test-file header, and docs/userspace-dataplane-architecture.md's "exactly two branches" paragraph. The PR body's tail cited rethProjectionNetdevs, a symbol retired two spellings ago, as the current mechanism; corrected.

Flagged and not from this work: TestRetiredLegNeverGainsARotatedCredential_5561 (pkg/api, arrived with the #6645 merge) failed once in a full-tree run and did not reproduce in 8 subsequent runs. This branch touches zero files under pkg/api/. Master-side flake candidate.

Full re-gate owed at this head.

@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Parent mutation proof at e17de05f1 — 2/2, and the over-reach control is sharper than it looks

Run in a private worktree at this head, restored clean.

MUT-1 — neuter the new reth clause. pkg/config stays ok; pkg/dataplane/userspace reds exactly the two new cells:

--- FAIL: TestRethNamingARedundantParentIsRejected_6722
    CompileConfig accepted an incoherent reth membership …
      [reth1 gigether-options redundant-parent reth0 …]
      [ge-0/0/1 redundant-parent reth1 + reth1 redundant-parent ge-0/0/1 …]
      [reth1 gigether-options redundant-parent ge-0/0/1 …]
--- FAIL: TestRethNamingARedundantParentMarksTheRethOnTheLenientPath_6722
    CompileConfigLenient recorded no reth-member warning …

All three strict shapes and the tolerant-admit half. No collateral in pkg/config, so the clause is bound without over-reaching into the sibling gates.

MUT-2 — neuter the self-parent clause (the over-reach control). This is the mutation that matters, because a new gate that silently subsumes an older gate's coverage leaves the older one vacuous while the suite stays green. TestSelfNamedRedundantParentIsRejected_6722 reds, and the first failure is the one that proves the point:

CompileConfig accepted an incoherent reth membership
  Config: [set interfaces ge-0/0/1 gigether-options redundant-parent ge-0/0/1 …]

A non-reth interface naming itself. The new clause tests strings.HasPrefix(name, "reth"), so it cannot catch this — only the self clause can. The self clause therefore still owns coverage the new clause does not provide, exactly as the round claims.

Worth recording explicitly, because the raw output could be misread: the same mutation also produces failures where the new clause fires in the self clause's place. For reth1 redundant-parent reth1 the new clause catches it and returns its own message, and the cell reports:

want it to contain "names itself": the rejection must come from the reth-member
coherence gate, not from an unrelated validator that happens to fire on this
config too

So the two clauses do overlap on a reth naming itself. That is fine and deliberate — ordering keeps the self clause's message — but the thing that keeps it fine is that the test asserts on the message text, not merely on "an error occurred". Without that assertion, deleting the self clause would leave the suite green for the reth-self case and only the non-reth case would notice. An error-occurred assertion would have made this control half-blind.

(Two further sub-cases in that output reject via an unrelated unit 0 validator on an st0 config and are correctly flagged by the same message assertion. The cell distinguishes "rejected" from "rejected by the right gate" throughout, which is the right shape for a gate whose neighbours also fire.)

On the emptiness argument

The round's claim is that the new clause empties rows 3 and 4 as a property: rethProjectionMembers only considers a candidate that declares a redundant-parent; once no reth* name may declare one, no candidate it sees is a reth* name, so snapshotLinuxName(name) is unconditionally LinuxIfName(name) and neither row is representable. That is the form I asked for across five rounds and did not get — an emptiness that follows from a clause rather than from a search that came up empty. It is also why the new clause had to test the identical strings.HasPrefix(name, "reth") the builder uses: a gate that classifies reth-ness differently from the code it protects re-opens the rows it closed.

Full re-gate still owed at this head — this proof covers the mutation grid only.

@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Hostile Claude at e17de05f1: MERGE-NEEDS-MINOR — zero blocking. The four-case table is complete; there is no fifth case.

This is the answer the PR has been trying to reach for seven rounds, and it arrives with the thing the previous six attempts lacked: a positive control.

Attack line 1 — COMPLETE, two independent legs

Structural. snapshotLinuxName is called from rethProjectionMembers with unit == nil always and a non-nil iface on both sides, and under those conditions its body has exactly two reachable arms. So reth / non-reth per side is the whole axis and 2×2 exhausts it. The sub-behaviours I asked about cannot split a row:

  • LinuxIfName only replaces / with -, and "reth" contains no /, so S(x) starts with reth iff x does — a reth-prefixed S can never cross into the non-reth half.
  • ResolveReth on an unmapped reth returns it unchanged; on a dotted ref it splits on . and classifies parts[0]. No third arm.
  • "ResolveReth returns something itself reth-prefixed" — the one that could genuinely have been a fifth case — is closed at the source: RethToPhysical's values are ifc.Name, which is always the map key (compiler_interfaces.go:31 sets Name: ifName, :555 keys on ifName). A value is therefore the key of an interface declaring a redundant-parent, and after the new clause no reth-prefixed key may declare one.
  • st* / gr* / VLAN / dotted-unit names are not a separate axis — they are non-reth names taking the non-reth arm.

Measured, because the structural argument is precisely what was wrong twice before. 324 ordered pair configs and ~4400 triple configs over an 18-name alphabet chosen to attack the named boundaries — reth (no digits), rethX, reth0.5, reth10, myreth0, st0, gr-0/0/0, fab0, lo0, em0, ae0, irb, and ge-0-0-1 beside ge-0/0/1 — each through the real CompileConfig and the real buildInterfaceSnapshots. Zero violations on the strict path.

And the part that makes it evidence rather than absence of evidence: the same sweep on the lenient path produces 24 reth-prefixed marks, all of them the documented row-3/row-4 shapes. The sweep can see the thing it is looking for. Six previous rounds produced searches that came up empty; this one produced a search that comes up empty and a control proving it would not have.

Plus the delta that actually matters: over a 5-zone-pattern × 110-pair space, the Rust ledger computed twice per strict-accepted config — honouring the mark and ignoring it — flagging every ifindex flipping AMBIGUOUS → resolved. 24 flips, 0 unsafe; in every one the winning zone was written by the operator on a name landing on that same netdev.

Four items to fold, none blocking

F1 — the stated tolerant-path bound is false on both halves. interfaces.go:526-529 says the marked reth "carries no units (the gate's unit clause covers it) and no zone". Measured: the unit clause is downgraded to a warning on the tolerant path exactly like the reth clause, so it covers nothing there — a marked reth1 with unit 0 compiles lenient and the row carries a unit. And "no zone" is wrong too; cell M contradicts itself two lines apart (:812 says the marked row carries no zone, :812-813 then says "a zoned reth still votes"), and its own 2-cycle sub-case has a marked and zoned row.

The safety conclusion survives — both variants land on the 0 sentinel, fail-closed — which is why this is not blocking. The real bound is (a) unit rows are never marked and (b) the zone.is_empty() half, both of which are bound. It is the stated reason that is wrong, in three places.

F2 — cell M's "no units" loop is non-distinguishing. No production edit can red it: unit rows are emitted iff the interface declares units, and the cell's config declares none. It asserts a property of its own fixture, under a comment claiming the opposite ("asserted rather than asserted-about"), justified by F1's false parenthetical. An assertion that a marked reth's unit row is unmarked would bind.

F3 — the validator message asserts a consequence one of its own shapes does not have. compiler_validate_strict_reth_member.go:134-136 states unconditionally that the builder "then marks %q as a PROJECTION". Measured: reth1 gigether-options redundant-parent ge-0/0/1 — cell L's own reth-names-a-physical shape — marks nothing. The preceding sentence is conditionalised; this one is not.

F5 (nit) — "master ... fail-closed against the 0 sentinel" is the right number by the wrong mechanism: master has no ambiguity ledger and reached 0 by last-write-wins. The Rust provenance comment states master's mechanism correctly, so this is an internal inconsistency.

Verified clean, with the measurement

The gate premise holds (gate and builder apply literally the same predicate to literally the same string). Boundary correct both ways — reth/rethX/reth0.5/reth10 rejected, myreth0 accepted. Not too wide: RedundantParent has one assignment site straight from the config leaf, and no shipped config, example or doc declares one on a reth. Row 2's #5832 dependency is exact.

The Rust half binds, with disjoint reds — the item nobody had checked. Forcing the projection flag false reds only the unzoned cell; dropping the && iface.zone.is_empty() conjunct reds only the explicitly-zoned cell. Both conjuncts individually bound, exactly as the production comment claims.

Wire field: names, types and semantics agree; no deny_unknown_fields; both skew directions degrade to fail-closed; both sides bound against a rename, with the Go test pinning omitempty too. The reviewer was scrupulous about the limit of its own check — the fixture-line conclusion came from reading the generator and the serde attributes, not from executing the drift test.

One correction to the round-7 record

With a short TMPDIR, the four pkg/dataplane/userspace failures the round-7 commit message attributes to the sun_path limit do not occur at all — the suite is fully green, not green-modulo-four. Worth fixing in the same pass, since a commit message that concedes four failures invites a future reader to accept four failures.

On F4

The reviewer filed a fifth item against the "only Go + a Rust comment" scoping, then withdrew it once I corrected the brief — the discrepancy was manufactured by my bad diff, not by the PR. It isolated round 7 correctly (git show --stat on the round's own non-merge commit, plus a comment-only check on the changed Rust file) and reached the same conclusion the lane had. Recorded because a withdrawn finding with its reasoning intact is more useful than a silently dropped one.

Round 8 of #6722, folding four claim/test items from the hostile review
at e17de05. Zero blocking; the four-case answer itself was verified
and holds. This changes no runtime behaviour on any accepted config --
the one production edit is a commit-check error MESSAGE.

F1. The stated tolerant-path bound was false on both halves, at three
sites: the `rethProjectionMembers` doc comment, cell M's doc block, and
the round-7 `_Log.md` entry. All three said the marked reth "carries no
units (the gate's unit clause covers it) and no zone".

The parenthetical is the load-bearing part and it is wrong: on the
TOLERANT path the unit clause is downgraded to a warning exactly like
the reth clause, so it covers nothing there. Measured with the real
`CompileConfigLenient` and the real `buildInterfaceSnapshots`,
`reth1 gigether-options redundant-parent reth0` beside `reth1 unit 0
family inet address 10.0.61.1/24` compiles, marks `reth1`, and emits a
`reth1.0` row. "and no zone" is wrong too -- adding `security-zone dmz
interfaces reth1` yields a marked row carrying `dmz`, and cell M's own
2-cycle sub-case already has `ge-0/0/1` marked AND zoned, which the
block contradicted two lines further down.

The bound is not a property of what the marked row carries. It is two
structural facts, and both are bound by assertions rather than by the
shape of a fixture:

  - A withheld vote is always an EMPTY one. The Rust gate is
    `reth_projection && zone.is_empty()`, so a marked row that names a
    zone still votes. Withholding can never discard a zone the operator
    wrote -- it can only let the ifindex resolve a zone another row on
    it named, or leave it with no contributing row and answer the 0
    sentinel. Bound Rust-side with disjoint reds.
  - UNIT rows are never marked. `buildInterfaceSnapshots` stamps
    `RethProjection: false` on every unit row unconditionally, so a
    grandfathered unit-carrying reth keeps voting through its units.

F2. Cell M's "no `reth1.*` row exists" loop asserted a property of its
own fixture -- that config declares no units, so no production edit
could red it -- under a comment claiming the opposite ("The bound,
asserted rather than asserted-about"), justified by F1's false
parenthetical. Replaced with a sub-case whose marked reth DOES carry a
unit: the base row must be marked and the unit row must not be.
Measured, stamping the unit row from the projection map instead of the
constant `false` now reds cell M as well as cell F; the old loop stayed
green under that mutation.

F3. The new commit-check message asserted unconditionally that the
builder "then marks %q as a PROJECTION of %q and withholds its
egress-zone vote". That is false for one of the gate's own shapes:
`reth1 gigether-options redundant-parent ge-0/0/1` -- cell L's
`reth-names-a-physical` sub-case -- marks nothing, because
S(reth1)="reth1" and S(ge-0/0/1)="ge-0-0-1". An operator hitting the
non-cycling shape was told a consequence their config does not have.
The message now splits the three cases: a reth parent lands the
parent's rows on a name no NIC carries and marks the reth; the
two-name cycle marks BOTH rows on the shared device; the non-cycling
case marks nothing but splits the resolvers.

F5. "an ifindex that was AMBIGUOUS -- fail-closed against the 0
sentinel" attributed this branch's agreement ledger to master. Master
has no ledger: `populate_egress` inserts one `egress` entry per
snapshot row keyed by ifindex, so the LAST row wins, and
`egress_zone_id` reads that map and answers 0 when the last row on the
ifindex is unzoned. The number was right, the mechanism was not, and
the Rust provenance comment on this branch already stated master's
mechanism correctly -- so this was an internal inconsistency. Corrected
in cell L's block and in the architecture doc, which now say what makes
the ledger different: 0 as the principled answer to DISAGREEMENT rather
than an artifact of row order.

Also corrected in `_Log.md`, since a commit message cannot be edited:
the round-7 message (3f99ba4) conceded "four pre-existing failures in
pkg/dataplane/userspace" as the `sun_path` 108-byte limit. Under a
SHORT TMPDIR there are no failures at all -- the package is ok and the
full `go test ./...` exits 0. They were an artifact of the long
GOTMPDIR that run used, not a property of the tree, and the message
should not have conceded them.

Validation. `go build ./...`, `go vet ./...`, `go test ./...` all
clean, 62 packages ok, rc 0, under a short TMPDIR. `cargo test
--release --bins --tests` in userspace-dp: 4419 passed, 0 failed.
gofmt clean on the three Go files. Mutation: stamping the unit row from
the projection map reds the new cell-M sub-case; neutering the reth
clause still reds exactly L1/L2/L3, so the F3 message rewrite kept the
rejection fragment those cells match on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Round 8 at 2baa6095b — all four items folded, and the new binder is stronger than claimed

The F2 replacement binds, and it reds TWO tests

Verified firsthand. Stamping the unit row from the projection map instead of the constant false:

--- FAIL: TestRethNamingARedundantParentMarksTheRethOnTheLenientPath_6722
    reth1.0 RethProjection = true, want false: the mark is stamped on BASE rows only.
    A marked unit row would withhold the vote of a real, independently addressed L3
    interface and hand its ifindex a zone the operator never wrote on it

--- FAIL: TestGrandfatheredMemberUnitStillVotes_6722
    ge-0/0/1.0 RethProjection = true, want false: the unit carries its own address
    10.9.9.1/30, installs a connected route on ifindex 24 and is an INDEPENDENT L3
    interface. Withholding its vote lets the ledger resolve `lan` for that ifindex,
    and a flow to 10.9.9.2 is then evaluated in the RETH's zone and PERMITTED where
    it must be denied — the measured #6722 fail-open

The round claimed the new sub-case binds alongside cell F. It does more than that: the second red is the original #6722 fail-open, stated as a traffic outcome rather than a field mismatch. So the replacement is not merely a distinguishing test where the old loop was vacuous — it is a second, independent witness to the defect this PR exists to fix.

The old loop stayed green under this same mutation. That comparative result is what makes the replacement a genuine new binder rather than a displacement of existing coverage.

The correction I owed

I wrote, summarising F1, that "both variants land on the 0 sentinel, fail-closed". That is false for the 2-cycle case, and the round was right to decline to repeat it: there the zoned row's mark is inert, a single row contributes, and the ifindex resolves lan rather than 0.

The round's replacement is better than the sentence I gave it, because it states an invariant that holds for every shape instead of a value that holds for two:

  1. a withheld vote is always an empty one — the Rust gate is reth_projection && zone.is_empty(), so withholding can never discard a zone the operator wrote, only let the ifindex resolve a zone another row named or answer the 0 sentinel;
  2. unit rows are never marked, so a grandfathered unit-carrying reth keeps voting through its units.

Declining to write a blanket "0" that had not been measured for every shape is exactly the right instinct, and it caught my error rather than inheriting it.

F1, F3, F5

F1 reproduced firsthand before anything was touched — a marked reth1 with unit 0 compiles lenient and emits reth1.0; adding a zone gives a row both marked and zoned; the 2-cycle sub-case has ge-0/0/1 marked and zoned lan. Corrected at all three sites, with the _Log.md correction added as a round-8 entry naming the superseded text rather than an edit to the round-7 entry — which keeps the union-merge invariant intact. That detail matters and it is easy to get wrong.

F3 now splits the three cases: reth parent, two-name cycle, and the non-cycling case that marks nothing but splits the resolvers (ResolveKernelIfName honours the map entry for a dotted ref while snapshotLinuxName does not, so units display on one device and forward on another). Cell L still matches on the "is a redundant-ethernet interface" fragment, re-verified by re-running the clause mutation.

F5 was checked against master's code rather than taken on trust: populate_egress inserts one egress entry per row keyed by ifindex, last row wins, and egress_zone_id reads that map — so master answers 0 when the last row on the ifindex happens to be unzoned. No ledger anywhere. Both sites now say what makes the ledger different: 0 as the principled answer to disagreement, not an artifact of row order.

The round-7 commit-message claim

Corrected in _Log.md, since the commit is immutable: with a short TMPDIR there are no failures at all — the package is ok and the full run exits 0. The four were an artifact of that run's long GOTMPDIR, not a property of the tree.

Gates

go build / go vet / go test ./... rc 0, 62 packages, zero failures. cargo test --release --bins --tests 4419 passed, 0 failed. The single production edit outside tests and comments is a commit-check error message; no runtime behaviour changes on any accepted config.

Re-gate owed at this head.

@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Hostile Claude interim at 2baa6095b — two measured claim defects, both NON-BLOCKING. One of them is mine.

My replacement invariant is half false, and the true bound is stronger

I asked the reviewer to check the invariant that replaced my own wrong sentence, on the grounds that I had already been wrong once on this exact point. It was worth asking.

Fact (1) — "a withheld vote is always an EMPTY one" — is SOUND, and is a tautology of the single call site: forwarding_build/interfaces.rs:244 is the only consumer of reth_projection in the Rust tree, row_zone_id is 0 exactly when zone.is_empty() (a non-empty unresolvable zone returns InterfaceUnknownZone rather than falling to 0), and the flush publishes only nonzero agreed values. So "resolve a zone another row named, or answer the 0 sentinel" is exhaustive.

Fact (2)'s second sentence is FALSE. interfaces.go:539-540 says a grandfathered reth carrying units "keeps voting through them, so its unzoned units still hold the shared ifindex ambiguous." That holds only for a unit-0 / non-VLAN unit. snapshotLinuxName (:441-442) sends a VLAN unit to L(R(name)).<vlan> — a different netdev — so it holds nothing ambiguous. Measured:

reth1 redundant-parent reth0 + reth1 unit 100 vlan-id 100 …
  ifindex 31: [reth0="lan"(proj=false)  reth1=""(proj=true)]   -> resolves lan
  ifindex 32: [reth1.100=""(proj=false)]

same config with unit 0:
  ifindex 31: [reth0="lan"(false) reth1=""(true) reth1.0=""(false)] -> disagreement -> 0

Non-blocking, and the reason is the interesting part: a stronger bound actually holds. Row 3 marks a reth only when that reth has no physical member of its own — if it had one, S(reth1) would be its member's netdev and the equality fails — so the marked netdev is the literal string "reth1", which no NIC carries. buildLinkSnapshot returns 0 and populate_interfaces skips ifindex <= 0. A row-3 mark is runtime-inert because its netdev does not exist. Replace sentence 2 rather than deleting fact (2).

The three-case message fixed one branch and left the same defect in another

This is the fourth instance today of a single pattern: the finding names one case, the fix addresses that case, and the sibling with the identical shape is left standing.

Round 8 corrected the non-cycling branch of the validator message — which was asserting a mark that shape does not have — and left the reth-parent branch asserting the same class of thing. compiler_validate_strict_reth_member.go:132-136 states unconditionally that with a reth parent "the parent's rows then land on the netdev name %q, which no NIC is ever named, and the snapshot builder marks %q a PROJECTION of %q". Measured false for a reth parent that already has a real member:

ge-0/0/2 redundant-parent reth0
reth1    redundant-parent reth0
  ResolveReth(reth0) = "ge-0/0/2"; reth0's rows land on ge-0-0-2 (ifindex 25)
  rethProjectionMembers = map[ge-0/0/2:true]   <- reth1 is NOT marked

Both halves of that sentence are false for this shape. The rejection is right; only the explanation is wrong — which is the same disposition the round already applied to the branch it did fix.

Verified clean

  • _Log.md is a pure append--numstat is 51/0, one hunk at EOF, no earlier entry edited in place, and the new entry explicitly names what it supersedes. The union-merge invariant holds.
  • The F2 replacement binds, confirmed independently: mutating RethProjection: falserethProjection[name] reds exactly two cells, cell F and cell M's new sub-case. The round's claim is accurate. Restored and sha256-matched against the review worktree byte-for-byte.

Rust-side conjunct mutations, the base-row/unit-row distinction at every emission site, and the full claim audit still running.

Round 9 of #6722, folding two measured claim defects plus three sibling
sites found by sweeping the predicate rather than the instance. No
runtime behaviour change on any accepted config, and no `.rs` file is
touched; the one production edit is a commit-check error MESSAGE.

ITEM 1. The tolerant-path bound's fact (2) said a grandfathered reth
carrying units "keeps voting through them, so its unzoned units still
hold the shared ifindex ambiguous". That holds only for a non-VLAN
unit 0, which collapses onto the base netdev. `snapshotLinuxName` sends
a VLAN unit to `L(R(name)).<vlan>` -- a different netdev -- so it bears
on its own ifindex and says nothing about the base's. Measured with the
real lenient compiler and the real builder:

  reth1 redundant-parent reth0 + reth1 unit 100 vlan-id 100
    ifindex 31: [reth0 "lan", reth1 "" MARKED]        -> resolves lan
    ifindex 32: [reth1.100 ""]
  the same with unit 0
    ifindex 31: [reth0 "lan", reth1 "" MARKED, reth1.0 ""] -> resolves 0

Fact (1) -- a withheld vote is always an EMPTY one -- is sound and
unchanged: `zone.is_empty()` is a conjunct of the only consumer of the
flag, so "resolve a zone another row named, or answer the 0 sentinel"
stays exhaustive.

The replacement is a STRONGER bound, not a weaker one: a row-3 mark is
RUNTIME-INERT. A row-3 mark needs `S(parent) == S(name)` with both
`reth*`. `S(name)` is the marked reth's own name unless something
declares it as a redundant parent, and if something does, `R(parent)`
is a member of `parent` while `R(name)` is a member of `name` -- two
different interfaces, so the equality would need a canonicalization
collision, which is #5832's shape rather than this one. So the marked
netdev is the literal string `rethN`, and on the bondless-RETH model
this whole mechanism exists for, a reth is not a kernel device:
`buildLinkSnapshot` answers ifindex 0 and both `populate_interfaces`
and `populate_egress` skip `ifindex <= 0`. The row never reaches the
ledger. Row 4 is NOT inert -- its netdev is a real physical member's --
and is bounded by fact (1) instead.

ITEM 2. The reth-parent branch of the new commit-check message asserted
unconditionally that the parent's rows land on the netdev name `rethN`
and that the builder marks the reth a projection. Measured false when
the parent already has a real physical member:

  ge-0/0/2 redundant-parent reth0
  reth1    redundant-parent reth0
    -> RethToPhysical[reth0] = ge-0/0/2, marks = {ge-0/0/2}
    -> reth1 is not marked at all

SWEEP, not reported: the predicate behind that finding is "an
unconditional sentence inside a multi-branch message", so all three
branches were checked rather than the one named. The CYCLE branch is
conditional too -- a two-name cycle whose reth has a lower-named third
member gives `RethToPhysical[reth1] = ge-0/0/0` and marks NEITHER cycle
row, marking the third member instead. The message now asserts only
what is unconditional -- the line enters the reth into
`RethToPhysical`'s scoring against the parent's real physical ports --
and states each consequence as a possibility, with an inline note
recording why, so the next edit does not re-introduce an outcome claim.

SWEEP on item 1's predicate found the same over-general inference at
three more sites, all now carrying the unit-0 qualifier: cell M's doc
block, the tail of `validateRethMemberStrict`'s own doc comment, and
fact 5 of the test-file header together with the matching paragraph in
docs/userspace-dataplane-architecture.md. Cell F's sentence is left as
written -- its config is a non-VLAN unit 0, so it is true as scoped.

New cell-M sub-case `reth-carrying-a-vlan-unit` pins the split: the
base row marked, the unit row exempt (as in the unit-0 sub-case), and
the unit row on a DIFFERENT ifindex with LinuxName `reth1.100`.

Validation. `go build ./...`, `go vet ./...`, `go test ./...` clean --
62 packages ok, rc 0, short TMPDIR. gofmt clean on the three Go files.
No `.rs` file is touched this round. Mutations: stamping the unit row
from the projection map reds BOTH cell-M sub-cases; collapsing the VLAN
unit onto the base netdev reds the new ifindex and LinuxName
assertions; neutering the reth clause still reds exactly L1/L2/L3 with
H, I and K green, so the message rewrite kept the fragment those cells
match on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Hostile Claude FINAL at 2baa6095b: MERGE-NEEDS-MINOR — zero blocking. Four claim defects, all textual.

Review worktree pristine, sha256-verified on all three production files before and after the matrix.

F1 — the F3 rewrite fixed one branch and left the same defect in another

compiler_validate_strict_reth_member.go:132-136 asserts unconditionally that with a reth parent "the parent's rows then land on the netdev name %q … and the snapshot builder marks %q a PROJECTION of %q". Measured false for a reth parent that already has a real physical member:

ge-0/0/2 redundant-parent reth0 ;  reth1 redundant-parent reth0
  ResolveReth(reth0) = "ge-0/0/2"      (node-affinity: InterfaceSlot("reth1") is -1,
                                        so it never scores 2; tie-break is lexicographic)
  reth0 / reth0.0 rows -> ge-0-0-2, ifindex 25
  rethProjectionMembers = map[ge-0/0/2:true]     <- reth1 NOT marked

Both halves are false for this shape. The rejection is right; the explanation is wrong. Cell L states the same fact correctly as a measurement of its own config — the message generalises it into an unconditional consequence.

A nit in the same sentence: "which no NIC is ever named" is an absolute the code does not enforce. ValidateDeviceMapLogicalName accepts any dot-free name of letters/digits/-//, so reth1 is an acceptable #1956 device-map binding target.

F2 — my replacement invariant: fact (1) sound, fact (2) sentence 2 false

Fact (1) verified structurally, not by sampling: forwarding_build/interfaces.rs:244 is the only consumer of reth_projection in the tree, row_zone_id is 0 exactly when zone.is_empty() (a non-empty unresolvable zone returns InterfaceUnknownZone rather than collapsing), and the flush publishes only nonzero agreed values. Also confirmed populate_egress now sources zone_id from the same ledger and no production site reads egress.zone_id directly — so both arms of egress_zone_id derive from the ledger and cannot disagree.

Fact (2) sentence 2 is false, measured on the 2-cycle, which lands on a real NIC:

unit 0    -> ifindex 24: [ge-0/0/1="lan"(marked) reth1=""(marked) reth1.0=""(unmarked)]
             -> disagreement -> 0            (the sentence works)
unit 100  -> ifindex 24: [ge-0/0/1="lan"(marked) reth1=""(marked)]
             ifindex 26: [reth1.100=""]      (a DIFFERENT netdev)
             -> unanimous -> resolves lan    (the sentence does not)

Non-blocking, and the reasoning is worth keeping: the VLAN variant lands on the same outcome as the already-documented no-unit 2-cycle delta, which cell M and the architecture doc already state and accept, and it stays inside fact (1)'s envelope — lan is a zone the operator literally wrote on a row on that device. The unit merely fails to restore fail-closed; it opens nothing fact (1) does not already permit.

The stronger bound to state instead: row 3 marks a reth only when it has no physical member of its own, so the marked netdev is the literal string "reth1", buildLinkSnapshot returns 0, and populate_interfaces skips ifindex <= 0. Row 3 is runtime-inert — with the honest caveat that this rests on no NIC being named reth1, which the device-map validator does not guarantee.

F3 — a false claim this PR introduced, in the doc comment of the function it describes

types/forwarding.rs:616-617 says the fallback carries an ifindex "only when EVERY snapshot row on it agreed". False at this head, and it contradicts line 584 of the same comment ("they are exempted from the ledger … rather than counted as dissent"). git blame puts it on this branch; a later commit on the same branch made it false. Every other site says "every CONTRIBUTING row" — this is the only survivor across all nine changed files plus the architecture doc.

Verified clean — firsthand

  • "Unit rows are never marked" is enforceable: it is a hardcoded false at interfaces.go:308, and that is the only unit-row emission site — two InterfaceSnapshot{...} literals total, builder.go:87 takes Interfaces only from there, the host-inbound post-pass writes only HostInbound*, and quarantineCollidingZones never touches it. A future edit routing around it must edit that line, and that line is bound.
  • The F2 replacement binds: mutating RethProjection: falserethProjection[name] reds exactly cell F and cell M's new sub-case. The sub-case also asserts markedUnit.Ifindex == markedBase.Ifindex, which is what makes it non-vacuous — and is precisely the co-location a VLAN unit does not have.
  • Rust conjuncts bind with disjoint reds: dropping zone.is_empty()1 red; dropping reth_projection9 reds, none overlapping.
  • Branches 2 and 3 of the message are accurate, with all ten format verbs checked against real emitted text.
  • _Log.md is a pure append (51/0, one hunk at EOF), and the F5 master-mechanism correction is accurate against origin/master directly.
  • Wire field: no deny_unknown_fields, both skew directions default to the pre-userspace-dp: resolve the to-zone of a MAC-less egress interface (IPsec xfrmi) #6722 fail-closed behaviour, and the fixture line is correct because protocol_wire_v1.json is generated from Rust Default::default() specimens — not a Go golden, so it does not conflict with omitempty.
  • Boundary: gate and builder apply literally the same HasPrefix to literally the same string; reth, rethX, retha0 all probed.
  • Validation reproduced under a short TMPDIR, confirming the round-8 retraction of the round-7 "four pre-existing failures" concession — there are none.

Fold: four textual items, no code

Conditionalise the reth-parent branch; replace fact (2)'s second sentence with the row-3 inertness bound; "EVERY snapshot row""every CONTRIBUTING row" at forwarding.rs:617; soften the device-map absolute.


Fleet note, unrelated to this PR. Three afxdp::wg::engine::engine_internal_tests cells plus the CoS-lease seqlock cell wedge under load_Log.md documents this as #6657, "an unbounded blocking recv wedging at 7 threads in __skb_wait_for_more_packets", and a prior lane attributed it on master at like-for-like scope. Five orphaned xpf_userspace_dp test binaries are currently wedged on this box at 8m, 11m, 40m, 49m, 172m and 188m. Any lane running a full cargo test --release right now may be sitting on a dead suite rather than a slow one. The correct response is the one taken here: --skip the documented family, re-run the baseline under the identical filter so per-cell counts stay comparable, and disclose both.

@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Round 9 at c9b020695 — two reported items folded, and the sweep found three more across two predicates

No runtime change; no .rs file touched. The single production edit is a commit-check message.

Item 1 — the runtime-inert bound, with the proof worked rather than restated

The measurement reproduces exactly: a VLAN unit lands on a different ifindex, so it holds nothing ambiguous and the shared ifindex resolves lan, where a unit-0 unit produces disagreement and 0.

Fact (2)'s first sentence is kept — unit rows are never marked, which is what cell F and the cell-M sub-case bind — and the second is replaced with the stronger bound, derived rather than asserted:

a row-3 mark needs S(parent) == S(name) with both reth, and S(name) is the marked reth's own name unless something declares it as a redundant parent — if something does, R(parent) is a member of parent and R(name) a member of name, two different interfaces, so the equality needs a canonicalization collision, i.e. #5832's shape, not this one.

Hence the marked netdev is the literal rethN, buildLinkSnapshot answers ifindex 0, and both populate_interfaces (:55) and populate_egress (:492) skip ifindex <= 0. Scoped explicitly to row 3 — row 4's netdev is a real member's and is bounded by fact (1).

Item 2 — and the sweep, which is the point

The reth-parent branch was confirmed false as measured. Then the predicate — "an unconditional sentence inside a multi-branch message" — was run against all three branches, and the cycle branch turned out conditional too:

ge-0/0/1 redundant-parent reth1 ; reth1 redundant-parent ge-0/0/1 ; ge-0/0/0 redundant-parent reth1
  -> RethToPhysical[reth1] = ge-0/0/0 ; marks = {ge-0/0/0} ; NEITHER cycle row marked

The near-miss is the most useful line in the report:

my first attempt used ge-0/0/3, which loses the lexicographic tie and reproduces the bare-cycle result — I had to pick a name that actually wins the scoring before the sibling appeared. A sweep that stops at the first negative result is not a sweep.

That is the failure mode a sweep is most exposed to: the predicate is right, the probe is run, the answer comes back negative — and the negative is an artifact of an input that never reached the case. A sweep needs inputs chosen to reach each branch, not merely to differ.

And rather than conditionalise three branches and leave a fourth to be found later, the round removed the branch enumeration entirely: the message now asserts only what is unconditional — that the line enters the reth into RethToPhysical's scoring against the parent's real physical ports — and states each consequence as a possibility, with an inline note recording why. That is the same move as encoding an invariant instead of narrowing a proxy, applied to prose.

The second sweep — item 1's predicate, three unreported sites

Grepping the surviving-unit-vote inference found it at three more places no review named: cell M's doc block, the tail of validateRethMemberStrict's doc comment, and fact 5 of the test-file header plus the matching architecture-doc paragraph. All now carry the unit-0 qualifier.

And cell F's sentence was deliberately left alone — its config is a non-VLAN unit 0, so the sentence is true as scoped:

rewriting a true sentence to match a pattern is how a sweep starts doing damage.

That is the necessary counter-discipline. A sweep that edits every syntactic match rather than every false one manufactures churn and can turn a correct statement into a hedged one.

Two reported instances, three unreported siblings, across two distinct predicates.

Binding

New cell-M sub-case reth-carrying-a-vlan-unit: base row marked, unit row exempt, unit row on a different ifindex with LinuxName reth1.100.

mutation result
stamp the unit row from the projection map reds both cell-M sub-cases, and cell F
collapse the VLAN unit onto the base netdev reds the new ifindex + LinuxName assertions
neuter the reth clause still reds exactly L1/L2/L3; H, I, K green

The third row is the over-reach control and it still holds — the new work has not widened the clause's blast radius.

Gates

go build / go vet / go test ./... rc 0 — 62 packages, short TMPDIR. cargo test --release --bins --tests 4419 passed / 0 failed. gofmt clean.

One process note worth carrying to other lanes

I used git checkout -- <file> to restore a mutation and it silently discarded an uncommitted edit in that same file.

Redone and verified. The safe restore in a worktree that carries uncommitted work is a file copy, not git checkout — the same class as the restore-clobbers-real-work failure already recorded this session, reached through a different command.

@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Gate at c9b020695: both legs blocking. Hostile Claude MERGE-NEEDS-MAJOR (1), Codex DO-NOT-MERGE (5).

Between them, four distinct runtime spellings where an interface loses or gains a
destination zone relative to master. All reachable, all measured end-to-end on both trees
rather than argued. Three fail closed; one fails open.

Codex also cleared its content filter on this PR for the first time — three prior attempts
died mid-run. The change was framing the brief in outcome terms ("is there a configuration
where an interface is assigned a destination zone belonging to a different interface")
rather than in the vocabulary of the defect.

The four runtime spellings

B1 (Claude) — the base row of a multi-unit interface votes a zone the operator never
wrote there.
I verified the crux myself at head: buildInterfaceZoneMap does
if _, exists := out[base]; !exists { out[base] = zoneName }, so the base entry takes
whichever unit reference is processed first — making the outcome turn on zone-name
alphabetical order
. With ge-0/0/1.0 in lan and ge-0/0/1.1 in dmz, unit 1 resolves
to ifindex 0 and is dropped, leaving ifindex 10 carrying the derived dmz and the real
lan. Measured: master egress_zone_id(10)=7 (lan), PR =0. Zone 0 matches no rule, so
permit becomes deny. The reviewer's controls both behave: rename dmzaaa so unit 0 sorts
first and it resolves green; a single unit 0 is unaffected.

C1 (Codex) — an authored dotted base aliases another interface's derived VLAN unit.
ge-0/0/1.100 as a redundant-parent member is legal, passes the RETH validator, and the
#5832 collision gate doesn't reject because the authored canonical names differ.
rethProjectionMembers compares only base rows, so it isn't exempted; the ledger sees
{empty, lan} on the shared ifindex and drops it. The PR removes the zone from an
explicitly zoned interface.

C2 (Codex) — a unitless WireGuard member receives the RETH's zone. Opposite polarity:
this one fails open. The RETH validator rejects member logical units but never inspects
ifc.Tunnel, and WireGuard's own validation accepts the shape. wg0 is admitted through
iface.tunnel.then_some([0; 6]) while the RETH rows fail the MAC gate — so an
independently routed endpoint inherits reth1's zone where master gives 0.

C3 (Codex) — combining the two lenient gates makes round 9's row-3 mark live. Both
R(reth0) and R(reth1) canonicalize to one netdev, so reth1 is marked and reth0's zone
is assigned across the independently authored side. Round 9's prose is correctly qualified
as inert "absent a #5832 collision" — that sentence is true. It just isn't a runtime bound
over accepted tolerant configs, where both gates are warnings. Same lesson as before: a
no-behaviour-change argued on the strict path says nothing about the path field configs take.

Test-binding

C5 — a genuinely vacuous test. The Some(0) short-circuit binder went vacuous at
ad4f0c113, when every EgressInterface.zone_id started sourcing from the same ledger the
fallback uses. "Egress row zone == 0" now implies "the ledger has no nonzero value", so
filtering zero before or_else still returns 0 and the test can't distinguish it. The
Round-3 PR-body claim that this mutation went red was accurate at that commit — the
rewrite invalidated it and neither the test nor the comment was updated.

C4 — I'm downgrading this one from Codex's blocking, and saying why. Codex notes that
deleting RethProjection: false leaves Go's zero value, so nothing changes and no
assertion fires. Literally true. But a line whose removal is a semantic no-op cannot
regress, and the meaningful mutation — substituting rethProjection[name] — the Claude leg
measured RED at three named assertions. So the invariant is bound; what's unbound is the
explicit-false line, whose value is documentary. That's a claim defect, not a coverage
hole, and the fix is not a test asserting a Go zero value.

What I actually want from round 10

This is the ninth spelling across the PR's life and the fourth in a single round, each
previous one closed by adding a case to a predicate. That count is itself the finding. Before
a fifth patch, the round owes an answer on whether the enumeration is the defect — whether
the exemption can be derived from "this row's zone was derived rather than authored"
instead of from a growing list of shapes that happen to derive it. If a structural
formulation exists, take it; if not, say why and I'll accept enumeration with the count
stated here.

One limitation Codex disclosed and I'm carrying forward: its worktree was read-only, so it
could not create fresh mutation copies. Only findings 4 and 5 come from the current source;
the rest of its assertion-binding table reuses earlier artifacts and is not re-measured at
this head.

@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

AGY leg at c9b020695: MERGE-CLEAN, 0 findings — and I am not counting it as a passing third leg.

Recording it because a clean verdict that goes unremarked reads later as corroboration.

AGY was asked the same first question as the other two legs: is there a configuration where
an interface is assigned a destination zone belonging to a different interface?
It answered:

"No, there is no sixth spelling."

Two other legs measured four, end-to-end on both trees, with reproducing configs.

The transcript shows why. AGY went looking for the answer in the prose — it searched
_Log.md and the commit messages for "five spellings" and "lexicographic tie" to work out
what earlier rounds had found — and then reasoned structurally about rethProjectionNetdevs'
three properties. It never built a config and never ran one through the builder. A structural
argument over a predicate cannot see a spelling that reaches the predicate through a different
row, which is exactly what all four findings do.

This is the documented behaviour for this leg on this codebase, so it isn't a surprise and
isn't a reason to drop it — an AGY finding still earns verification. But an AGY clean is
not evidence, and it never unblocks a merge on its own.

Gate stands at: hostile Claude MERGE-NEEDS-MAJOR (1 blocking), Codex DO-NOT-MERGE (5 blocking).

@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Round 10 answered the design question before writing the fifth patch — the enumeration IS the defect, and it is being replaced

I asked round 10 not to patch a fifth case until it said whether the enumeration approach was
itself the problem. The answer, with all four spellings reproduced firsthand first:

The Rust ledger asks "do the rows sharing this ifindex agree about its zone?" — but it can
only see the outcome of two Go-side derivations whose inputs it cannot see:
buildInterfaceZoneMap's fan-up/fan-down, which stamps a zone on rows the operator never
zoned, and snapshotLinuxName's aliasing, which lands several config identities on one
netdev. Nine spellings have all been attempts to reconstruct provenance from that
outcome by classifying rows. Provenance is not recoverable from the outcome. That is the
defect — not any particular missing case.

Measured, not asserted — zoneMap[ge-0/0/1]="dmz" and zoneMap[reth0]="wan" on the HA cluster
are both derived zones the operator never wrote on those rows.

The replacement: stop reconstructing, carry the answer

Go computes the egress zone per ifindex, because Go is the only place holding both missing
inputs — the authored security-zone <z> interfaces <X> references before any derivation, and
snapshotLinuxName itself. Three rules, in order: authored (resolve every authored
reference through snapshotLinuxName; two distinct zones on one ifindex → ambiguous);
trunk carrier (a base netdev carrying only tagged children inherits its units' unanimous
authored zone); contested ownership (two or more distinct logical identities on one ifindex
not linked by a valid reth membership → ambiguous, where valid is one positive definition
used by the runtime and the strict validator).

Rust then deletes the entire zone_agreement voting block, the reth_projection field and
rethProjectionMembers, keeping one check that needs no predicate: honour egress_zone only if
some row on that ifindex literally carries that zone name.

What it makes unrepresentable: there is no per-row classification predicate left in the
dataplane. Every one of the nine spellings was "this row's Zone, or its absence, is not the
independent statement the ledger took it for"
— and a row's Zone is no longer consulted for
adjudication, so there is nothing for a new config shape to disagree with.

What it does not, stated by the round rather than dragged out of it: "a reth member is a
bare L2 port" is a MODEL rule, not a derivable one. It is irreducibly a definition and it stays
a definition
— but in one place, expressed positively, read by both gate and runtime. C2's
missing "no tunnel" clause is a hole in that definition, not a tenth reconstruction.

Three additions I sent back

  1. This deletes a wire field. reth_projection is on both sides of the snapshot protocol
    with serialization guards bound by tests. Both sides are at version 5. The mixed-version
    matrix has to be measured in both directions — if either produces a wrong zone rather than
    a clean rejection, that is a fail-open across an upgrade and it needs a bump, not a comment.
  2. Rule 2 is load-bearing for the shipped cluster config by the round's own analysis:
    without it, HA ifindex 25 fails closed against both master and head. So it gets a fixture
    built from docs/ha-cluster-userspace.conf itself, and this round owes a cluster smoke — I
    schedule it.
  3. The interfaces: canonical Linux-name collisions overwrite zone and routing identity #5832 prediction is a behaviour change beyond the four findings. Retiring that
    fail-open delta is the right outcome, but it changes what a currently-accepted config does
    and must be argued separately with its own before/after, not folded in as a side effect.

Predicted, to be reported as measured: B1 → lan, C1 → lan (both = master), C2 → 0, C3 → 0,
HA ifindex 24 → lan, HA ifindex 25 → wan.

psaab pushed a commit that referenced this pull request Aug 13, 2026
Round 10 of #6722 arrived with FOUR more runtime spellings of one defect,
from two independent reviewer legs — the sixth through ninth across the
PR's life, four of them in a single round. Each earlier spelling had been
closed by adding a case to a predicate. This round replaces the predicate
instead, because the count is the finding.

WHY THE ENUMERATION KEPT FAILING

The Rust agreement ledger asked "do the rows sharing this ifindex agree
about its zone?" and grew an exemption list for the rows whose agreement
or dissent turned out to be an artefact. It cannot answer that soundly: a
row's Zone is the OUTCOME of two Go-side derivations whose inputs the rows
no longer carry.

buildInterfaceZoneMap fans one authored reference up to a base and down
onto units. Measured on the real builder: for `ge-0/0/1` with unit 0 in
`lan` and unit 1 in `dmz`, the BASE row carries "dmz" — a zone nothing on
that netdev was ever put in, picked because "dmz" sorts before "lan".
snapshotLinuxName then collapses several configured identities onto one
netdev. By the time a row exists, "the operator zoned this identity" and
"another identity was zoned and this row inherited the words" look
identical. Every spelling was an attempt to reconstruct that provenance
downstream, and provenance is not recoverable from the outcome.

THE REPLACEMENT

authoredZoneRefs (zones.go) records the operator's literal
`security-zone <z> interfaces <ref>` bindings before any derivation.
stampEgressZones (interfaces.go) resolves them through the same aliasing
the builder performs and decides, per ifindex, the zone that ifindex
EGRESSES into. Three rules, in order:

  1. CONTESTED OWNERSHIP -> no zone. Two egress identities on one netdev
     with no valid reth membership between them.
  2. AUTHORED -> that zone. Exactly one distinct literal binding wins; two
     or more is a real conflict about a real device.
  3. TRUNK CARRIER -> the units' unanimous zone, for an ifindex with no
     authored binding and no unit row on it. This is what keeps the
     reference cluster's `reth0` base zoned `wan`, matching origin/master.

The answer ships as InterfaceSnapshot.EgressZone. rethProjectionMembers
and the reth_projection wire field are deleted: there is no per-row
classification predicate on the dataplane side any more, so there is
nothing left for a new config shape to disagree with. What the helper
still does is CORROBORATE — it honours the answer only where a row on that
ifindex literally names that zone, preserving the #2391/#2409/#2706
property that a drifted or hostile snapshot cannot conjure a zone no row
named.

MEASURED, through the real CompileConfig + buildInterfaceSnapshots

  shape                                        c9b0206   now    master
  B1 ge-0/0/1 units 0/1 lan/dmz, ifindex 10    0           lan    lan
  B1 control: rename dmz->aaa (sorts first)    green       lan    lan
  B1 control: single unit 0                    lan         lan    lan
  C1 authored ge-0/0/1.100 aliases reth1.100   0           lan    lan
  C2 WireGuard wg0 as a reth member            lan OPEN    none   none
  C3 reth-as-member + #5832 collision          lan OPEN    none   none
  reference HA cluster ifindex 24 / 25         lan / wan   same   same

WHAT IS NOT MADE UNREPRESENTABLE, stated plainly

"A reth member is a bare L2 port — no logical units, no tunnel, not itself
a reth" is a model rule imported from Junos, not something derivable from
the config. It stays a definition, but it now lives in ONE place, stated
positively, and is read by both validateRethMemberStrict (hard reject at
commit) and egressMemberIsBarePort (the runtime half that holds the line
on the tolerant load / peer-sync path, where the rejection is a warning
per #1960). C2's missing clause was a hole in that definition — a
WireGuard interface configures no logical unit, so the existing unit
clause could not see it — not a tenth reconstruction.

THE VACUOUS BINDER

egress_zone_id's Some(0) short-circuit went vacuous at ad4f0c1, when
populate_egress began sourcing EgressInterface::zone_id from the same
ledger the fallback read: both arms then returned the same number for
every state, and the claimed binder's mutation (filter zero before
or_else) still returned 0. The resolver is now a single map read — exactly
equivalent for every state, with no branch left to mutate — and the doc
says so instead of calling the short-circuit load-bearing.

WIRE ENCODING

EgressZone is emitted unconditionally and decoded as Option<String>,
because ABSENT (a Go binary predating the field) and EMPTY (a decision
that the ifindex identifies no zone) must be distinguishable. Absent falls
back to the pre-#6722 row-unanimity rule, which has NO exemption list — so
every shared-netdev shape this issue was holed by resolves to nothing
under it rather than to a wrong zone, and it is strictly narrower than
origin/master.

CLAIM CORRECTIONS FOLDED

The round-9 commit-check remedy pointed the operator at the wrong
interface (the %q had moved from `name` to `parent`, so "remove the
redundant-parent line from it" named a physical port that carries no such
line), and its first consequence was asserted across a branch where it is
unreachable. Both are fixed and each consequence now names its sub-branch.
Three near-verbatim restatements of the corrected predicate — in
compiler_opts.go, compiler_uniformgates_routing_rib_rpm.go and the
architecture doc — are rewritten rather than patched, because the
mechanism they describe is gone. A trailing comment block in
userspace-dp/src/afxdp/forwarding/tests.rs announced "the four tests
below" binding a three-conjunct projection gate; there were no tests below
it at all.

VALIDATION

Full Go suite for ./pkg/... + ./cmd/... and the full userspace-dp cargo
suite, both green; the protocol wire golden regenerated via
XPF_PROTOCOL_WIRE_REGEN=1; make audit-check refreshed and
pkg/refactoraudit green. A 15-cell mutation matrix reverts each production
hunk individually — 11 Go, 4 Rust — and every cell reds on a named
assertion; two cells that first came back green were fixtures that could
not distinguish the branch they named, and both were rewritten rather than
accepted. Not smoked on the cluster: the ha-cluster-userspace.conf
measurement above is a Go-side measurement, not a traffic test.
@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Round 10 at 3270fcb2e — the enumeration is gone, replaced. One blocking item remains, and it is a wire-contract one I found after the round reported.

The structural fix landed

Provenance is now carried, not reconstructed. authoredZoneRefs records the literal
security-zone <z> interfaces <ref> bindings before any derivation; stampEgressZones
resolves them through the same aliasing the builder performs and decides the egress zone per
ifindex; it ships as InterfaceSnapshot.EgressZone. rethProjectionMembers and the
reth_projection wire field are deleted — there is no per-row classification predicate on
the dataplane side any more, so there is nothing left for a tenth config shape to disagree with.

All four spellings measured fixed, each back to master's answer or better:

head before now
B1 multi-unit base 0 lan (= master)
C1 authored dotted base 0 lan (= master)
C2 WireGuard member lan (fail-OPEN) "" (= master)
C3 two lenient gates lan "" (= master)

Both B1 controls are in the test, not just reproduced — the dmzaaa rename asserted as a
precondition so the fixture cannot silently stop distinguishing, and the single-unit-0 case.
C2 was fixed at both ends: the runtime rule and the strict validator, which now rejects a
member carrying its own tunnel — the unit clause could not see it because WireGuard configures
no logical unit.

C5 agreed and fixed. The Some(0) short-circuit went vacuous exactly as Codex described;
the resolver now collapses to a single map read, with no branch left to mutate, and its doc says
so rather than calling the short-circuit load-bearing.

Two things the round found in itself and reported rather than quietly fixing, which is the
part I'd highlight: a trailing comment announcing "the four tests below" with no tests below
it
— the file ended at the comment; and two of its own mutation cells came back GREEN,
meaning two fixtures could not distinguish the branch they named. One zoned a member as
ge-0/0/1.0 where that interface configures no unit, so the ref named a row that does not
exist; the other used a fixture whose member row is unzoned, so no row-based rule resolves
anything there either. Both rewritten.

BLOCKING — the wire contract changed at an unchanged version

I asked for the mixed-version matrix to be measured in both directions. It is not in the
report, so I checked:

master  protocol.go:37   ProtocolVersion = 4      head  protocol.go:37   ProtocolVersion = 4
master  control.rs       VERSION = 4              head  control.rs       VERSION = 4

A field was deleted and another added, and both sides still advertise 4 — the version
running in the field. Two binaries that both say "4" now interpret the same bytes differently,
and no gate can distinguish them.

The PR's own Rust doc states the consequence: egress_zone is Option with
#[serde(default)], and the fallback arm is "strictly narrower than origin/master and can
only fail closed."
Narrower is right for a bug and wrong for an unsignalled upgrade — an old
control plane with a new helper sends no egress_zone, the helper defaults to None, takes the
narrower arm, and traffic master would forward lands on the default policy. A partial outage
with nothing in the logs pointing at a version.

I am not accepting "the field is optional and unknown fields are ignored, so both directions
degrade rather than corrupt." That argument was made on #6691 this afternoon in nearly the same
words and Codex broke it — and #6691's case was weaker, since it bumped 4→5 and the colliding
version had never shipped. This one collides with the version in the field.

Sent back: measure both directions as measurements; bump to 5 on both sides with the assertion
pinned to equality rather than > 4 (the shape that stays green at exactly the colliding
value); add a required-capability sentinel; and say in the commit message that the wire contract
changed and what upgrade ordering it implies.

Deliberate deltas, stated rather than folded in

Rule 3 (trunk carrier) exists because without it the reference cluster's ifindex 25 fails closed
against both master and head; it fires only when no authored binding and no unit row sit on the
ifindex, so it cannot reopen B1. And the #5832 canonical-collision shape now fails closed
where the previous head resolved the operator's zone from the other name — retiring a fail-open
the old doc admitted.

Smoke

The round says plainly: "a green smoke would not be evidence about B1/C1/C2/C3, the tests
are."
That is correct and stays in the PR body. I am scheduling one anyway, because the
resolver and the wire both moved — it is evidence about the reference topology and the
forwarding path, not about the four spellings.

psaab pushed a commit that referenced this pull request Aug 13, 2026
Round 10 of #6722 arrived with FOUR more runtime spellings of one defect,
from two independent reviewer legs — the sixth through ninth across the
PR's life, four of them in a single round. Each earlier spelling had been
closed by adding a case to a predicate. This round replaces the predicate
instead, because the count is the finding.

WHY THE ENUMERATION KEPT FAILING

The Rust agreement ledger asked "do the rows sharing this ifindex agree
about its zone?" and grew an exemption list for the rows whose agreement
or dissent turned out to be an artefact. It cannot answer that soundly: a
row's Zone is the OUTCOME of two Go-side derivations whose inputs the rows
no longer carry.

buildInterfaceZoneMap fans one authored reference up to a base and down
onto units. Measured on the real builder: for `ge-0/0/1` with unit 0 in
`lan` and unit 1 in `dmz`, the BASE row carries "dmz" — a zone nothing on
that netdev was ever put in, picked because "dmz" sorts before "lan".
snapshotLinuxName then collapses several configured identities onto one
netdev. By the time a row exists, "the operator zoned this identity" and
"another identity was zoned and this row inherited the words" look
identical. Every spelling was an attempt to reconstruct that provenance
downstream, and provenance is not recoverable from the outcome.

THE REPLACEMENT

authoredZoneRefs (zones.go) records the operator's literal
`security-zone <z> interfaces <ref>` bindings before any derivation.
stampEgressZones (interfaces.go) resolves them through the same aliasing
the builder performs and decides, per ifindex, the zone that ifindex
EGRESSES into. Three rules, in order:

  1. CONTESTED OWNERSHIP -> no zone. Two egress identities on one netdev
     with no valid reth membership between them.
  2. AUTHORED -> that zone. Exactly one distinct literal binding wins; two
     or more is a real conflict about a real device.
  3. TRUNK CARRIER -> the units' unanimous zone, for an ifindex with no
     authored binding and no unit row on it. This is what keeps the
     reference cluster's `reth0` base zoned `wan`, matching origin/master.

The answer ships as InterfaceSnapshot.EgressZone. rethProjectionMembers
and the reth_projection wire field are deleted: there is no per-row
classification predicate on the dataplane side any more, so there is
nothing left for a new config shape to disagree with. What the helper
still does is CORROBORATE — it honours the answer only where a row on that
ifindex literally names that zone, preserving the #2391/#2409/#2706
property that a drifted or hostile snapshot cannot conjure a zone no row
named.

MEASURED, through the real CompileConfig + buildInterfaceSnapshots

  shape                                        c9b0206   now    master
  B1 ge-0/0/1 units 0/1 lan/dmz, ifindex 10    0           lan    lan
  B1 control: rename dmz->aaa (sorts first)    green       lan    lan
  B1 control: single unit 0                    lan         lan    lan
  C1 authored ge-0/0/1.100 aliases reth1.100   0           lan    lan
  C2 WireGuard wg0 as a reth member            lan OPEN    none   none
  C3 reth-as-member + #5832 collision          lan OPEN    none   none
  reference HA cluster ifindex 24 / 25         lan / wan   same   same

WHAT IS NOT MADE UNREPRESENTABLE, stated plainly

"A reth member is a bare L2 port — no logical units, no tunnel, not itself
a reth" is a model rule imported from Junos, not something derivable from
the config. It stays a definition, but it now lives in ONE place, stated
positively, and is read by both validateRethMemberStrict (hard reject at
commit) and egressMemberIsBarePort (the runtime half that holds the line
on the tolerant load / peer-sync path, where the rejection is a warning
per #1960). C2's missing clause was a hole in that definition — a
WireGuard interface configures no logical unit, so the existing unit
clause could not see it — not a tenth reconstruction.

THE VACUOUS BINDER

egress_zone_id's Some(0) short-circuit went vacuous at ad4f0c1, when
populate_egress began sourcing EgressInterface::zone_id from the same
ledger the fallback read: both arms then returned the same number for
every state, and the claimed binder's mutation (filter zero before
or_else) still returned 0. The resolver is now a single map read — exactly
equivalent for every state, with no branch left to mutate — and the doc
says so instead of calling the short-circuit load-bearing.

WIRE ENCODING

EgressZone is emitted unconditionally and decoded as Option<String>,
because ABSENT (a Go binary predating the field) and EMPTY (a decision
that the ifindex identifies no zone) must be distinguishable. Absent falls
back to the pre-#6722 row-unanimity rule, which has NO exemption list — so
every shared-netdev shape this issue was holed by resolves to nothing
under it rather than to a wrong zone, and it is strictly narrower than
origin/master.

CLAIM CORRECTIONS FOLDED

The round-9 commit-check remedy pointed the operator at the wrong
interface (the %q had moved from `name` to `parent`, so "remove the
redundant-parent line from it" named a physical port that carries no such
line), and its first consequence was asserted across a branch where it is
unreachable. Both are fixed and each consequence now names its sub-branch.
Three near-verbatim restatements of the corrected predicate — in
compiler_opts.go, compiler_uniformgates_routing_rib_rpm.go and the
architecture doc — are rewritten rather than patched, because the
mechanism they describe is gone. A trailing comment block in
userspace-dp/src/afxdp/forwarding/tests.rs announced "the four tests
below" binding a three-conjunct projection gate; there were no tests below
it at all.

MIXED-VERSION WIRE MATRIX, MEASURED

This round REMOVES `reth_projection` from a contract both sides read, which is
not the same as removing an internal predicate, so both upgrade directions were
measured on the real binaries rather than argued.
CONFIG_SNAPSHOT_PROTOCOL_VERSION stays at 4. The repo bumps it when an old
reader MISREADS a snapshot into a wrong answer — #5488's
ErrScopedGlobalZoneSetProtocolIncompatible is the model: an old helper reads
only the singular match_from_zone and NARROWS a global deny, a fail-OPEN.
Neither direction here misreads.

  new Go -> OLD helper. The helper at c9b0206, fed the wire shape this
  builder emits, deserializes it — there is no `deny_unknown_fields` anywhere
  in userspace-dp/src/protocol, at that commit or this one — reads
  reth_projection = false from serde's default, and answers
  ledger[24] = None, to_zone = 0, action = Deny.

  old Go -> NEW helper. The retired key is ignored, egress_zone arrives None,
  the compatibility arm requires unanimity, the member's unzoned row dissents,
  and the answer is 0. Pinned by
  old_go_wire_shape_into_new_helper_fails_closed_6722.

Both directions LOSE the fix in a mixed window and neither invents a zone: a
partially-upgraded bondless-RETH cluster keeps blackholing until both halves
land, which is what it did already. That is what makes the pair additive.

A DELIBERATE BEHAVIOUR CHANGE BEYOND THE FOUR FINDINGS

The #5832 canonical-collision-without-a-reth shape — two names that merely
canonicalize onto one device, rejected at commit but ADMITTED on the tolerant
load / peer-sync path — measured on all three trees:

  origin/master (edefb75)   egress_zone_id(24) = 0
  PR head c9b0206           resolves `lan`        <-- fail-OPEN
  here                        egress_zone_id(24) = 0

At the previous head the collision row is marked a projection (measured:
RethProjection = true), its empty vote is withheld, and the ledger resolves the
zone the operator wrote on the OTHER name for that device — a fail-OPEN the
PR's own doc admitted in passing. `egressRethMemberOf` requires the PARENT to
be a `reth*`, so neither name is the other's member port and the ownership is
contested. The net effect RESTORES master and retires a delta an earlier round
of this PR introduced. It is called out as its own change, not as a side effect
of the refactor, because an operator holding such a config sees the difference.

THE SHIPPED CLUSTER CONFIG IS NOW A FIXTURE

Rule 3 makes docs/ha-cluster-userspace.conf a live dependency of a rule this
round introduces: without it, ifindex 25 (reth0's untagged base) fails closed
against both master and the previous head.
TestShippedClusterConfigResolvesBothRethIfindexes_6722 parses that file through
the real parser, the real `${node}` group expansion and the real CompileConfig,
and asserts ifindex 24 = lan / 25 = wan — so an edit that moves a zone binding
off reth0.50/reth0.80, or adds an untagged unit to reth0, fails here instead of
on a cluster.

VALIDATION

Full Go suite for ./pkg/... + ./cmd/... and the full userspace-dp cargo
suite, both green; the protocol wire golden regenerated via
XPF_PROTOCOL_WIRE_REGEN=1; make audit-check refreshed and
pkg/refactoraudit green. A 15-cell mutation matrix reverts each production
hunk individually — 11 Go, 4 Rust — and every cell reds on a named
assertion; two cells that first came back green were fixtures that could
not distinguish the branch they named, and both were rewritten rather than
accepted. The retained AF_XDP shim object is UNCHANGED (no diff under
userspace-xdp/ or any .o), so this round adds no shim-ABI risk. Not smoked on
the cluster: every measurement above is a Go/Rust unit measurement, not a
traffic test, and the forwarding path moves enough here that a real DUT run is
owed before merge.
@psaab
psaab force-pushed the fix/6713-xfrmi-tozone branch from 3270fcb to f03a73c Compare August 13, 2026 22:28
@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Wire-version item: withdrawn. No bump owed — the round measured it and I was applying the wrong criterion.

I called this blocking. It is not, and the correction is mine.

The matrix, measured on real binaries rather than reasoned about from serde attributes — the
round built the old helper, fed it the new Go builder's wire shape, and read the number:

new Go -> OLD helper    ledger[24] = None, to_zone = 0, action = Deny
old Go -> NEW helper    retired key ignored, egress_zone = None, to_zone = 0   (now a committed test)

Neither direction produces a wrong zone. Both degrade to the same fail-closed answer the
pairing gave before this PR
— that is, to the bug this PR fixes, not to a new one. I had assumed
a mixed window would newly drop traffic master forwards. It does not; an operator mid-upgrade is
not-yet-better rather than worse. Supporting facts checked rather than assumed: no
deny_unknown_fields anywhere in the protocol module at either commit, and the old struct's
retired field carries #[serde(default)].

The repo's own bump criterion, which I should have asked for instead of importing one. This
project bumps when an old reader misreads a snapshot into a wrong answer — the model is
ErrScopedGlobalZoneSetProtocolIncompatible, whose comment explains that an old helper reads
only the singular match_from_zone and narrows a global deny: a fail-OPEN. Additive fields
have landed at version 4 repeatedly without a bump, including the very field this round retires.

The decisive argument, which I did not have: every non-test caller of
build_forwarding_state sits behind the exact-equality version gate, so a bump would make the
compatibility arm production-unreachable while ~40 existing fixtures still exercise it —
trading a stated, fail-closed degradation for dead code plus fixtures modelling an impossible
snapshot.

The round also stated plainly what a bump would buy, rather than burying it: it converts a
mixed window from a silent blackhole into a loud commit abort with the helper still
forwarding last-good. That is a real operator difference, it is why this is a judgement call
rather than an obvious no, and the argument is preserved in the commit message so it can be
reopened cheaply if the disarm machinery is added later.

I was generalising from two other PRs today where a bump was owed, and applying that
generalisation as settled. The round measured; I had inferred.

Two other corrections to my earlier comment

The #5832 item is a restoration, not a new behaviour change. I described it as a deliberate
delta beyond the four findings. Measured on all three trees:

master head c9b020695 now
#5832 collision alone, ifx 24 none lan (fail-OPEN) none

An earlier round of this PR introduced the fail-open; this round retires it. Versus master it
changes nothing. Still argued separately in the commit message, correctly — as a restoration.

C5 stays. I said delete it if no binder can distinguish it. The round measured that it still
reds — left: 1, right: 0 on which map the egress half reads — so it was mislabelled, not
vacuous
. Declining my instruction with evidence is the right call; the doc and the assertion
message are rewritten to say what it actually binds.

And rule 3 now has the fixture I asked for

TestShippedClusterConfigResolvesBothRethIfindexes_6722 reads docs/ha-cluster-userspace.conf
through the real parser, the real ${node} expansion and the real CompileConfig, asserting
ifindex 24 = lan / 25 = wan, with preconditions that fail loudly if a conf edit moves a zone
binding or puts a unit on reth0's base. The live dependency now fails in a test rather than on
a cluster.

Cluster smoke is mine and is being scheduled at f03a73c83. The retained shim object is
unchanged, so this is a forwarding-path run, not an ABI one — and the round's own framing holds:
a green smoke is evidence about the reference topology, not about B1/C1/C2/C3. The tests are.

@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Cluster smoke at f03a73c83forwarding clean, failover 14/14, and the first run's failure was my harness

Binary identity proved on both nodes before anything ran:

==> Verified running xpfd on loss:xpf-userspace-fw0 matches the pushed build
    (6c1871fa8fb6ca776a8d169dd7a3afb04b68b2b53feff01b230916a143d1db82)
==> Verified running xpfd on loss:xpf-userspace-fw1 ... (same hash)
cluster-deploy rc=0

Forwarding — the leg that matters for a zone-resolution change

v4 push:  4.72 GBytes  8.11 Gbits/sec   0 retr
v4 rev:   4.36 GBytes  7.49 Gbits/sec   0 retr
v6 push:  4.45 GBytes  7.64 Gbits/sec   0 retr
v6 rev:   4.44 GBytes  7.63 Gbits/sec   0 retr
[SUM] 12-stream reverse:  26.7 GBytes  22.9 Gbits/sec  197 retr

Transit forwards on the reference topology in all four single-stream cells with zero
retransmits
, and the 12-stream reverse reproducer reaches line rate.

One thing I am not glossing: the multi-stream cell shows 197 retransmits, and the
documented pass criterion for that reproducer is zero. At 22.9 Gb/s over 10 s that is ~0.0002%,
and I do not have a same-session master baseline to compare against, so I am not calling it
either a regression or noise — recording it as observed. If a later run on an unrelated PR shows
the same figure it is ambient; if the next #6722 run shows it again and master does not, it is
worth chasing.

Failover — 14/14, on the second attempt, and the first failure was mine

PASS  fw0 is primary                        PASS  fw1 remains primary after fw0 rejoin
PASS  iperf3 target reachable               PASS  iperf3 survived fw0 rejoin
PASS  iperf3 running on the LAN host        PASS  fw0 became primary for all RGs after failover
PASS  fw0 has 10 established sessions       PASS  iperf3 survived manual failover
PASS  fw1 has 10 synced sessions            PASS  iperf3 completed successfully
PASS  iperf3 survived fw0 reboot            PASS  iperf3 throughput: 22.0 Gbps
PASS  fw0 xpfd restarted after reboot       PASS  fw0 rejoined as secondary (no auto-preempt)

The first run aborted with FATAL: fw0 is not primary — cannot run failover test. That was my
settle gate, not this PR.
After a deploy node 0 stays secondary by design, and my gate only
checked that someone held ≥2 primary rows — which fw1 satisfies alone. So the gate passed while
the precondition the failover test needs was false, and the leg never ran.

The re-run adds an explicit request chassis cluster failover ... node 0 for both redundancy
groups and then gates on node0.*primary specifically, which satisfied on the first poll.

It also re-asserts the binary identity by sha256 on both nodes before skipping the deploy — a
re-run that skips deploy without checking certifies whatever happens to be installed, which is a
mistake I made earlier today on a different PR and will not repeat silently.

Consistent with the round's own framing, which I am keeping: a green smoke is evidence about the
reference topology and the forwarding path, not about B1/C1/C2/C3 — those are bound by tests,
including the new fixture that reads docs/ha-cluster-userspace.conf through the real parser and
compiler.

psaab pushed a commit that referenced this pull request Aug 13, 2026
Round 10 of #6722 arrived with FOUR more runtime spellings of one defect,
from two independent reviewer legs — the sixth through ninth across the
PR's life, four of them in a single round. Each earlier spelling had been
closed by adding a case to a predicate. This round replaces the predicate
instead, because the count is the finding.

WHY THE ENUMERATION KEPT FAILING

The Rust agreement ledger asked "do the rows sharing this ifindex agree
about its zone?" and grew an exemption list for the rows whose agreement
or dissent turned out to be an artefact. It cannot answer that soundly: a
row's Zone is the OUTCOME of two Go-side derivations whose inputs the rows
no longer carry.

buildInterfaceZoneMap fans one authored reference up to a base and down
onto units. Measured on the real builder: for `ge-0/0/1` with unit 0 in
`lan` and unit 1 in `dmz`, the BASE row carries "dmz" — a zone nothing on
that netdev was ever put in, picked because "dmz" sorts before "lan".
snapshotLinuxName then collapses several configured identities onto one
netdev. By the time a row exists, "the operator zoned this identity" and
"another identity was zoned and this row inherited the words" look
identical. Every spelling was an attempt to reconstruct that provenance
downstream, and provenance is not recoverable from the outcome.

THE REPLACEMENT

authoredZoneRefs (zones.go) records the operator's literal
`security-zone <z> interfaces <ref>` bindings before any derivation.
stampEgressZones (interfaces.go) resolves them through the same aliasing
the builder performs and decides, per ifindex, the zone that ifindex
EGRESSES into. Three rules, in order:

  1. CONTESTED OWNERSHIP -> no zone. Two egress identities on one netdev
     with no valid reth membership between them.
  2. AUTHORED -> that zone. Exactly one distinct literal binding wins; two
     or more is a real conflict about a real device.
  3. TRUNK CARRIER -> the units' unanimous zone, for an ifindex with no
     authored binding and no unit row on it. This is what keeps the
     reference cluster's `reth0` base zoned `wan`, matching origin/master.

The answer ships as InterfaceSnapshot.EgressZone. rethProjectionMembers
and the reth_projection wire field are deleted: there is no per-row
classification predicate on the dataplane side any more, so there is
nothing left for a new config shape to disagree with. What the helper
still does is CORROBORATE — it honours the answer only where a row on that
ifindex literally names that zone, preserving the #2391/#2409/#2706
property that a drifted or hostile snapshot cannot conjure a zone no row
named.

MEASURED, through the real CompileConfig + buildInterfaceSnapshots

  shape                                        c9b0206   now    master
  B1 ge-0/0/1 units 0/1 lan/dmz, ifindex 10    0           lan    lan
  B1 control: rename dmz->aaa (sorts first)    green       lan    lan
  B1 control: single unit 0                    lan         lan    lan
  C1 authored ge-0/0/1.100 aliases reth1.100   0           lan    lan
  C2 WireGuard wg0 as a reth member            lan OPEN    none   none
  C3 reth-as-member + #5832 collision          lan OPEN    none   none
  reference HA cluster ifindex 24 / 25         lan / wan   same   same

WHAT IS NOT MADE UNREPRESENTABLE, stated plainly

"A reth member is a bare L2 port — no logical units, no tunnel, not itself
a reth" is a model rule imported from Junos, not something derivable from
the config. It stays a definition, but it now lives in ONE place, stated
positively, and is read by both validateRethMemberStrict (hard reject at
commit) and egressMemberIsBarePort (the runtime half that holds the line
on the tolerant load / peer-sync path, where the rejection is a warning
per #1960). C2's missing clause was a hole in that definition — a
WireGuard interface configures no logical unit, so the existing unit
clause could not see it — not a tenth reconstruction.

THE VACUOUS BINDER

egress_zone_id's Some(0) short-circuit went vacuous at ad4f0c1, when
populate_egress began sourcing EgressInterface::zone_id from the same
ledger the fallback read: both arms then returned the same number for
every state, and the claimed binder's mutation (filter zero before
or_else) still returned 0. The resolver is now a single map read — exactly
equivalent for every state, with no branch left to mutate — and the doc
says so instead of calling the short-circuit load-bearing.

WIRE ENCODING

EgressZone is emitted unconditionally and decoded as Option<String>,
because ABSENT (a Go binary predating the field) and EMPTY (a decision
that the ifindex identifies no zone) must be distinguishable. Absent falls
back to the pre-#6722 row-unanimity rule, which has NO exemption list — so
every shared-netdev shape this issue was holed by resolves to nothing
under it rather than to a wrong zone, and it is strictly narrower than
origin/master.

CLAIM CORRECTIONS FOLDED

The round-9 commit-check remedy pointed the operator at the wrong
interface (the %q had moved from `name` to `parent`, so "remove the
redundant-parent line from it" named a physical port that carries no such
line), and its first consequence was asserted across a branch where it is
unreachable. Both are fixed and each consequence now names its sub-branch.
Three near-verbatim restatements of the corrected predicate — in
compiler_opts.go, compiler_uniformgates_routing_rib_rpm.go and the
architecture doc — are rewritten rather than patched, because the
mechanism they describe is gone. A trailing comment block in
userspace-dp/src/afxdp/forwarding/tests.rs announced "the four tests
below" binding a three-conjunct projection gate; there were no tests below
it at all.

THE WIRE CONTRACT CHANGED. ConfigSnapshotProtocolVersion 4 -> 5.

OPERATORS: xpfd and the userspace dataplane helper must be upgraded TOGETHER.
`make cluster-deploy` / `make test-deploy` already push and restart both, so the
supported path needs no new step. A PARTIAL upgrade — one half moved, the other
still on a v4 build — is now refused loudly instead of silently mis-forwarding:
the helper rejects the snapshot (its apply_snapshot and bump_fib_generation
gates are exact-equality) and the commit ABORTS with
ErrEgressZoneProtocolIncompatible naming the observed version and the remedy,
while the running helper keeps forwarding its previous-good image.

WHY A BUMP, when this repo does not bump for additive fields. This is a field
DELETION (`reth_projection`) paired with an addition (`egress_zone`), and a
deletion cannot ride an unchanged version: two binaries built either side of it
both advertise the same number and read the same bytes differently, with nothing
on the wire to tell them apart. The version that would have collided is 4 —
the one master ships — so the collision is with binaries that are deployed.

And the mixed pairing is not merely "not yet fixed". MEASURED, feeding the v4 Go
builder's rows to the v5 helper on docs/ha-cluster-userspace.conf (node 0):

  ifindex 24   egress zone 0   (origin/master and the matched v5 pair: lan)
  ifindex 25   egress zone 0   (origin/master and the matched v5 pair: wan)

Ifindex 25 settles it: the mixed pairing loses a zone even the PRE-#6722 helper
resolved, so it is strictly worse than either endpoint rather than an
intermediate state. Under `default-policy deny-all` that is a silent transit
outage carrying a version number both sides agree on. An earlier revision of
this work claimed both directions "degrade to the same fail-closed answer the
pairing gave before this PR"; that claim was too strong and this measurement is
what refuted it.

The other direction was measured on the real binary too: the v5 Go builder's
rows fed to the helper at c9b0206 deserialize (there is no
`deny_unknown_fields` anywhere in userspace-dp/src/protocol, at that commit or
this one), read `reth_projection = false` from serde's default, and answer
`ledger[24] = None, to_zone = 0, action = Deny`.

THE GATE IS KEYED ON EQUALITY, NOT `>=`

ensureEgressZoneProtocolLocked (manager_compile.go) refuses to commit against a
running helper whose advertised ConfigSnapshotProtocolVersion is not exactly
this binary's. `>=` would pass a helper NEWER than xpfd, whose own
exact-equality gate would then refuse our snapshot anyway — and a `> N` spelling
stays green at precisely the value that collides, which is the shape this gate
exists to catch. A dedicated test cell drives a helper at ProtocolVersion + 1
and reds under `>=`.

Two properties the gate deliberately does NOT have. It takes no config, because
every snapshot carries EgressZone and there is no shape to test. And it is
conditional on having actually OBSERVED a helper version: `lastStatus` is zero
before the first handshake, so firing on "version unknown" would abort every
commit made while the helper is down or starting — a brick, not a fence
(#1960). When no version can be learned it returns nil and the pre-existing
behaviour stands.

A DELIBERATE BEHAVIOUR CHANGE BEYOND THE FOUR FINDINGS

The #5832 canonical-collision-without-a-reth shape — two names that merely
canonicalize onto one device, rejected at commit but ADMITTED on the tolerant
load / peer-sync path — measured on all three trees:

  origin/master (edefb75)   egress_zone_id(24) = 0
  PR head c9b0206           resolves `lan`        <-- fail-OPEN
  here                        egress_zone_id(24) = 0

At the previous head the collision row is marked a projection (measured:
RethProjection = true), its empty vote is withheld, and the ledger resolves the
zone the operator wrote on the OTHER name for that device — a fail-OPEN the
PR's own doc admitted in passing. `egressRethMemberOf` requires the PARENT to
be a `reth*`, so neither name is the other's member port and the ownership is
contested. The net effect RESTORES master and retires a delta an earlier round
of this PR introduced. It is called out as its own change, not as a side effect
of the refactor, because an operator holding such a config sees the difference.

THE SHIPPED CLUSTER CONFIG IS NOW A FIXTURE

Rule 3 makes docs/ha-cluster-userspace.conf a live dependency of a rule this
round introduces: without it, ifindex 25 (reth0's untagged base) fails closed
against both master and the previous head.
TestShippedClusterConfigResolvesBothRethIfindexes_6722 parses that file through
the real parser, the real `${node}` group expansion and the real CompileConfig,
and asserts ifindex 24 = lan / 25 = wan — so an edit that moves a zone binding
off reth0.50/reth0.80, or adds an untagged unit to reth0, fails here instead of
on a cluster.

VALIDATION

Full Go suite for ./pkg/... + ./cmd/... and the full userspace-dp cargo
suite, both green; the protocol wire golden regenerated via
XPF_PROTOCOL_WIRE_REGEN=1; make audit-check refreshed and
pkg/refactoraudit green. A 15-cell mutation matrix reverts each production
hunk individually — 11 Go, 4 Rust — and every cell reds on a named
assertion; two cells that first came back green were fixtures that could
not distinguish the branch they named, and both were rewritten rather than
accepted. The retained AF_XDP shim object is UNCHANGED (no diff under
userspace-xdp/ or any .o), so this round adds no shim-ABI risk. Not smoked on
the cluster: every measurement above is a Go/Rust unit measurement, not a
traffic test, and the forwarding path moves enough here that a real DUT run is
owed before merge. A green smoke would be evidence about the reference topology
and the forwarding path; it is NOT evidence about B1/C1/C2/C3 — the tests are,
because none of those four shapes appears in the shipped cluster config.

Two unrelated flakes seen once each under full-suite parallelism and not
reproducible: pkg/ddns TestRFC2136UpsertNeverSendsDeleteRRset (3/3 green on
re-run) and slowpath::tests::enqueue_refuses_frame_above_live_mtu ("slow-path
worker is not running", 3/3 green on re-run). Neither file is touched by this
PR.
@psaab
psaab force-pushed the fix/6713-xfrmi-tozone branch from f03a73c to b556066 Compare August 13, 2026 22:41
@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Smoke at b5560662b (protocol 4→5): PASS — forwarding clean, failover 14/14, 23.0 Gbps

The head moved after the previous smoke, and it moved materially — the protocol constant on
both sides plus a new commit-time gate. So the f03a73c83 run does not cover this head and I
re-ran rather than carrying it forward.

The deploy is itself evidence:

==> Verified running xpfd on loss:xpf-userspace-fw0 matches the pushed build
    (f0ab2bc4d1c57b9d0a0d88e463fd7ae827411080d40afa43ccfe6f595e2a25e9)
cluster-deploy rc=0

That hash differs from the pre-bump run's (6c1871fa…), which confirms the smoke exercised
the bumped code rather than a stale binary. A cluster deploy is the right instrument here
precisely because it pushes and restarts both halves — Go and the helper must agree at
runtime, and the version constant is what they agree on.

v4 push:  4.59 GB  7.89 Gb/s   0 retr        v6 push:  4.56 GB  7.83 Gb/s   0 retr
v4 rev:   4.39 GB  7.55 Gb/s   0 retr        v6 rev:   4.32 GB  7.42 Gb/s   0 retr
[SUM] 12-stream reverse:  20.1 GB  17.3 Gb/s  342 retr

14/14 failover PASS — reboot, rejoin-as-secondary, no auto-preempt, manual failover,
iperf3 survived all three transitions, 23.0 Gbps

One number I am flagging rather than smoothing over: the 12-stream reverse showed 342
retransmits
here against 197 on the pre-bump run, and the documented criterion for that
cell is zero. I am not calling it a regression — I have no same-session master baseline, both
runs were on a shared box under concurrent load, and the single-stream cells are clean in every
family. But two runs is not a trend, so if the next push moves the forwarding path again I want
a third data point before anyone reads one into it.

The fw0 is not primary abort on the first attempt was my settle gate, not this PR: after a
deploy node 0 stays secondary by design, and the gate only checked that someone held ≥2 primary
rows. Both re-runs now reassert node 0 explicitly and gate on node0.*primary.

Why the bump is here, since it reverses a call I made earlier on this PR

I raised the wire change as blocking, the round refuted it with a measurement, I accepted
and then the round went back, measured the direction it had not, and refuted itself:

master pre-round-10 head mixed pairing
HA ifindex 24 lan 0 0
HA ifindex 25 wan wan nothing

Ifindex 25 is the counterexample. Master forwards it. The buggy head forwards it. The mixed
window resolves nothing — strictly worse than either endpoint, which is a new outage caused
by the upgrade rather than a not-yet-fixed bug.

The round's own diagnosis of its error is the reusable part: it had measured the shape the
issue is about
, where 0 genuinely is the pre-existing bug, and generalised to the whole
snapshot. The compatibility arm is narrower than master everywhere, so the failing case was
never going to be the motivating shape.

The bump is 4→5 both sides, gated on equality with a cell driving a helper at version+1,
and deliberately conditional on having observed a helper version — an unconditional gate would
abort every commit made while the helper is down, a brick rather than a fence.

Still owed and in flight: migrating the 54 fixtures that model the now-unreachable compatibility
arm, and measuring rather than reading the "nothing half-applied before the gate fires" claim.
Both move the head again, so this smoke will be re-run once more when the push is final.

@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Withdrawing the retransmit flag

I flagged 197 retransmits on the 12-stream reverse here, then 342 after the protocol bump, and said two runs were not a trend. There is now a third data point from the same cluster and the same cell on an unrelated PR (#6815): 85.

#6722 pre-bump   197        #6722 post-bump   342        #6815 (unrelated)   85

Three values spanning 4× across independent change sets is ambient variance on a shared box under concurrent load, not a signal about this PR. Withdrawing the flag rather than leaving it attached indefinitely.

The single-stream cells remain the ones worth watching, and they are zero retransmits in every family across all three runs.

psaab pushed a commit that referenced this pull request Aug 13, 2026
The protocol bump needs its compatibility story measured rather than
reasoned, and measured on the shapes the clusters actually run — not the
shape the change was designed around. #6722 bumped 4 -> 5 earlier the
same day on a matrix that measured one ifindex and generalised; the
counterexample was an ifindex master forwarded, the buggy head forwarded,
and the mixed pairing dropped, strictly worse than both endpoints.

The motivating shape here is the least likely to expose a compatibility
defect, because it is the one the secure-tunnel gate was built for. So
the first thing measured is what a mixed pairing does to a cluster with
NO secure tunnel.

THE REFERENCE CLUSTER ARMS NOTHING. docs/ha-cluster-userspace.conf parses
and strict-compiles to 16 interface rows and ZERO SecureTunnel rows, so
on the loss userspace cluster the v6 gate is inert and the only mechanism
in a mixed pairing is the helper's version-equality check. The
mixed-version behaviour there is therefore identical in kind to every
previous bump.

BOTH OPERAND ORDERS REFUSE, against the real dispatcher. The existing
test drove CONFIG_SNAPSHOT_PROTOCOL_VERSION - 1 (a v5 control plane
meeting this v6 helper) through handle_stream over a socketpair. The
other direction — a v6 control plane meeting a v5 HELPER — cannot be run
without a v5 binary, and what it depends on is that `snapshot.version !=
CONST` refuses whichever side is ahead. That was read from the symmetry
of one line; a new cell drives `+ 1` and measures it. Weakening the check
to `<` makes the new cell red.

THE COMMIT CONSEQUENCE, both directions, with the REAL error. The
existing #5679 proof injects a generic apply failure, which leaves the
step from "the helper refused on version" to "the commit fails via the
deferred path" as a read of the classification. The new daemon test
injects the actual string the helper emits, wrapped the way
process_control.go and publishSnapshotFailClosedLocked wrap it, in both
orders: the commit FAILS, the apply was attempted once, the error is not
abort-class, and the peer config-sync is not skipped.

The answer is a clean refusal in both directions — no wrong answers. But
the two halves of "aborts the commit with the helper still forwarding"
belong to different paths, and the asymmetry is deliberate:

  v6 CP -> v5 helper, no secure tunnel : commit FAILS (deferred), helper
      refused the snapshot and stays ARMED on previous-good, forwarding
  v5 CP -> v6 helper, no secure tunnel : same
  v6 CP -> v5 helper, WITH secure tunnel : commit ABORTS, helper DISARMED

A helper that cannot PARSE the snapshot keeps forwarding what it already
enforces, because disarming it would convert a handshake disagreement
into a dataplane outage. A helper that would MISENFORCE it — a pre-v6
reader that ignores secure_tunnel and plans an AF_XDP binding for the
xfrmi — is disarmed, because forwarding under a rule it reads wrongly is
worse than not forwarding. Both classifications are asserted so neither
can drift into the other, and both planes are asserted at 6 in the same
test, since a bump that moved only one side would make every pairing a
mismatch including matched deployments.
Round 10 of #6722 arrived with FOUR more runtime spellings of one defect,
from two independent reviewer legs — the sixth through ninth across the
PR's life, four of them in a single round. Each earlier spelling had been
closed by adding a case to a predicate. This round replaces the predicate
instead, because the count is the finding.

WHY THE ENUMERATION KEPT FAILING

The Rust agreement ledger asked "do the rows sharing this ifindex agree
about its zone?" and grew an exemption list for the rows whose agreement
or dissent turned out to be an artefact. It cannot answer that soundly: a
row's Zone is the OUTCOME of two Go-side derivations whose inputs the rows
no longer carry.

buildInterfaceZoneMap fans one authored reference up to a base and down
onto units. Measured on the real builder: for `ge-0/0/1` with unit 0 in
`lan` and unit 1 in `dmz`, the BASE row carries "dmz" — a zone nothing on
that netdev was ever put in, picked because "dmz" sorts before "lan".
snapshotLinuxName then collapses several configured identities onto one
netdev. By the time a row exists, "the operator zoned this identity" and
"another identity was zoned and this row inherited the words" look
identical. Every spelling was an attempt to reconstruct that provenance
downstream, and provenance is not recoverable from the outcome.

THE REPLACEMENT

authoredZoneRefs (zones.go) records the operator's literal
`security-zone <z> interfaces <ref>` bindings before any derivation.
stampEgressZones (interfaces.go) resolves them through the same aliasing
the builder performs and decides, per ifindex, the zone that ifindex
EGRESSES into. Three rules, in order:

  1. CONTESTED OWNERSHIP -> no zone. Two egress identities on one netdev
     with no valid reth membership between them.
  2. AUTHORED -> that zone. Exactly one distinct literal binding wins; two
     or more is a real conflict about a real device.
  3. TRUNK CARRIER -> the units' unanimous zone, for an ifindex with no
     authored binding and no unit row on it. This is what keeps the
     reference cluster's `reth0` base zoned `wan`, matching origin/master.

The answer ships as InterfaceSnapshot.EgressZone. rethProjectionMembers
and the reth_projection wire field are deleted: there is no per-row
classification predicate on the dataplane side any more, so there is
nothing left for a new config shape to disagree with. What the helper
still does is CORROBORATE — it honours the answer only where a row on that
ifindex literally names that zone, preserving the #2391/#2409/#2706
property that a drifted or hostile snapshot cannot conjure a zone no row
named.

MEASURED, through the real CompileConfig + buildInterfaceSnapshots

  shape                                        c9b0206   now    master
  B1 ge-0/0/1 units 0/1 lan/dmz, ifindex 10    0           lan    lan
  B1 control: rename dmz->aaa (sorts first)    green       lan    lan
  B1 control: single unit 0                    lan         lan    lan
  C1 authored ge-0/0/1.100 aliases reth1.100   0           lan    lan
  C2 WireGuard wg0 as a reth member            lan OPEN    none   none
  C3 reth-as-member + #5832 collision          lan OPEN    none   none
  reference HA cluster ifindex 24 / 25         lan / wan   same   same

WHAT IS NOT MADE UNREPRESENTABLE, stated plainly

"A reth member is a bare L2 port — no logical units, no tunnel, not itself
a reth" is a model rule imported from Junos, not something derivable from
the config. It stays a definition, but it now lives in ONE place, stated
positively, and is read by both validateRethMemberStrict (hard reject at
commit) and egressMemberIsBarePort (the runtime half that holds the line
on the tolerant load / peer-sync path, where the rejection is a warning
per #1960). C2's missing clause was a hole in that definition — a
WireGuard interface configures no logical unit, so the existing unit
clause could not see it — not a tenth reconstruction.

THE VACUOUS BINDER

egress_zone_id's Some(0) short-circuit went vacuous at ad4f0c1, when
populate_egress began sourcing EgressInterface::zone_id from the same
ledger the fallback read: both arms then returned the same number for
every state, and the claimed binder's mutation (filter zero before
or_else) still returned 0. The resolver is now a single map read — exactly
equivalent for every state, with no branch left to mutate — and the doc
says so instead of calling the short-circuit load-bearing.

WIRE ENCODING

EgressZone is emitted unconditionally and decoded as Option<String>,
because ABSENT (a Go binary predating the field) and EMPTY (a decision
that the ifindex identifies no zone) must be distinguishable. Absent falls
back to the pre-#6722 row-unanimity rule, which has NO exemption list — so
every shared-netdev shape this issue was holed by resolves to nothing
under it rather than to a wrong zone, and it is strictly narrower than
origin/master.

CLAIM CORRECTIONS FOLDED

The round-9 commit-check remedy pointed the operator at the wrong
interface (the %q had moved from `name` to `parent`, so "remove the
redundant-parent line from it" named a physical port that carries no such
line), and its first consequence was asserted across a branch where it is
unreachable. Both are fixed and each consequence now names its sub-branch.
Three near-verbatim restatements of the corrected predicate — in
compiler_opts.go, compiler_uniformgates_routing_rib_rpm.go and the
architecture doc — are rewritten rather than patched, because the
mechanism they describe is gone. A trailing comment block in
userspace-dp/src/afxdp/forwarding/tests.rs announced "the four tests
below" binding a three-conjunct projection gate; there were no tests below
it at all.

THE WIRE CONTRACT CHANGED. ConfigSnapshotProtocolVersion 4 -> 5.

OPERATORS: xpfd and the userspace dataplane helper must be upgraded TOGETHER.
`make cluster-deploy` / `make test-deploy` already push and restart both, so the
supported path needs no new step. A PARTIAL upgrade — one half moved, the other
still on a v4 build — is now refused loudly instead of silently mis-forwarding:
the helper rejects the snapshot (its apply_snapshot and bump_fib_generation
gates are exact-equality) and the commit ABORTS with
ErrEgressZoneProtocolIncompatible naming the observed version and the remedy.

That abort is FAIL-CLOSED, not keep-forwarding, and an earlier revision of this
message got it backwards. ErrEgressZoneProtocolIncompatible joins
requiredProtocolGateSentinels, so the control plane refuses BEFORE it publishes,
disarmSnapshotProtocolFailClosedLocked DISARMS the helper, and transit falls to
the kernel path (#2138). A bump with no gate would have been the keep-forwarding
shape; the gate deliberately trades that availability for a loud, legible
refusal. Measured against a recording helper in
egress_zone_failclosed_6722_test.go rather than argued off the call graph: the
sentinel is returned, a set_forwarding_state{Armed:false} really reaches the
helper, and NO apply_snapshot is sent, so nothing is half-applied. A
matched-version control proves the gate does not fence the ordinary path.
Both properties are mutation-bound: relocating the gate to after apply_snapshot
reds the ordering assertion (requests seen: [apply_snapshot status
set_forwarding_state{armed:false}]), and dropping the disarm while keeping the
gate reds the disarm assertion (requests seen: [status]) — different assertions,
so neither cell's RED can be the other's.

THE COMPATIBILITY ARM IS DELETED and the 32 fixture literals stamped. The bump
makes that arm production-unreachable — every non-test caller sits behind the
exact-equality gate — so keeping it would have left ~40 fixtures exercising a
snapshot the wire cannot carry. `egress_zone` is a plain String again, the
stamping goes through one deliberately-weaker test helper (`test_fixtures::v5`,
which fills only EMPTY rows on ifindexes with no explicit stamp), and the one
test that covered only the deleted arm is RETIRED with its reason recorded
rather than rewritten into something that resembles coverage.

The risk in that migration is a fixture that starts passing for a NEW reason, so
it was measured on sandboxed copies rather than argued. Mutating the consumer to
source the egress row's zone_id from the row's own zone name — origin/master's
behaviour — reds exactly ONE test tree-wide, and the same mutation on the
PRE-migration tree reds the SAME one. The migration removed no binding.

The three migrated tests picked as likely discriminators all stayed green, and
the reason is structural: for an ifindex with a single configured identity the
ledger's answer and the row's own zone are the same value, so no mutation
swapping one for the other is observable. What that did expose is a real gap:
the single surviving discriminator asserts a 0 SENTINEL, so the positive
direction of B1 — the ledger's non-zero answer beating a dissenting row — had no
cell at all. egress_row_zone_is_order_invariant_not_last_write_6722 is that
cell: the reference LAN ifindex resolved in emission order and reversed, where
reversing puts the unzoned member row last so last-write-wins reads 0 and the
ledger reads `lan`. Green on the tree, RED under the mutation with
`left: 0, right: 1`.

WHY A BUMP, when this repo does not bump for additive fields. This is a field
DELETION (`reth_projection`) paired with an addition (`egress_zone`), and a
deletion cannot ride an unchanged version: two binaries built either side of it
both advertise the same number and read the same bytes differently, with nothing
on the wire to tell them apart. The version that would have collided is 4 —
the one master ships — so the collision is with binaries that are deployed.

And the mixed pairing is not merely "not yet fixed". MEASURED, feeding the v4 Go
builder's rows to the v5 helper on docs/ha-cluster-userspace.conf (node 0):

  ifindex 24   egress zone 0   (origin/master and the matched v5 pair: lan)
  ifindex 25   egress zone 0   (origin/master and the matched v5 pair: wan)

Ifindex 25 settles it: the mixed pairing loses a zone even the PRE-#6722 helper
resolved, so it is strictly worse than either endpoint rather than an
intermediate state. Under `default-policy deny-all` that is a silent transit
outage carrying a version number both sides agree on. An earlier revision of
this work claimed both directions "degrade to the same fail-closed answer the
pairing gave before this PR"; that claim was too strong and this measurement is
what refuted it.

The other direction was measured on the real binary too: the v5 Go builder's
rows fed to the helper at c9b0206 deserialize (there is no
`deny_unknown_fields` anywhere in userspace-dp/src/protocol, at that commit or
this one), read `reth_projection = false` from serde's default, and answer
`ledger[24] = None, to_zone = 0, action = Deny`.

THE GATE IS KEYED ON EQUALITY, NOT `>=`

ensureEgressZoneProtocolLocked (manager_compile.go) refuses to commit against a
running helper whose advertised ConfigSnapshotProtocolVersion is not exactly
this binary's. `>=` would pass a helper NEWER than xpfd, whose own
exact-equality gate would then refuse our snapshot anyway — and a `> N` spelling
stays green at precisely the value that collides, which is the shape this gate
exists to catch. A dedicated test cell drives a helper at ProtocolVersion + 1
and reds under `>=`.

Two properties the gate deliberately does NOT have. It takes no config, because
every snapshot carries EgressZone and there is no shape to test. And it is
conditional on having actually OBSERVED a helper version: `lastStatus` is zero
before the first handshake, so firing on "version unknown" would abort every
commit made while the helper is down or starting — a brick, not a fence
(#1960). When no version can be learned it returns nil and the pre-existing
behaviour stands.

A DELIBERATE BEHAVIOUR CHANGE BEYOND THE FOUR FINDINGS

The #5832 canonical-collision-without-a-reth shape — two names that merely
canonicalize onto one device, rejected at commit but ADMITTED on the tolerant
load / peer-sync path — measured on all three trees:

  origin/master (edefb75)   egress_zone_id(24) = 0
  PR head c9b0206           resolves `lan`        <-- fail-OPEN
  here                        egress_zone_id(24) = 0

At the previous head the collision row is marked a projection (measured:
RethProjection = true), its empty vote is withheld, and the ledger resolves the
zone the operator wrote on the OTHER name for that device — a fail-OPEN the
PR's own doc admitted in passing. `egressRethMemberOf` requires the PARENT to
be a `reth*`, so neither name is the other's member port and the ownership is
contested. The net effect RESTORES master and retires a delta an earlier round
of this PR introduced. It is called out as its own change, not as a side effect
of the refactor, because an operator holding such a config sees the difference.

THE SHIPPED CLUSTER CONFIG IS NOW A FIXTURE

Rule 3 makes docs/ha-cluster-userspace.conf a live dependency of a rule this
round introduces: without it, ifindex 25 (reth0's untagged base) fails closed
against both master and the previous head.
TestShippedClusterConfigResolvesBothRethIfindexes_6722 parses that file through
the real parser, the real `${node}` group expansion and the real CompileConfig,
and asserts ifindex 24 = lan / 25 = wan — so an edit that moves a zone binding
off reth0.50/reth0.80, or adds an untagged unit to reth0, fails here instead of
on a cluster.

VALIDATION

Full Go suite for ./pkg/... + ./cmd/... and the full userspace-dp cargo
suite, both green; the protocol wire golden regenerated via
XPF_PROTOCOL_WIRE_REGEN=1; make audit-check refreshed and
pkg/refactoraudit green. A 15-cell mutation matrix reverts each production
hunk individually — 11 Go, 4 Rust — and every cell reds on a named
assertion; two cells that first came back green were fixtures that could
not distinguish the branch they named, and both were rewritten rather than
accepted. The retained AF_XDP shim object is UNCHANGED (no diff under
userspace-xdp/ or any .o), so this round adds no shim-ABI risk. Not smoked on
the cluster: every measurement above is a Go/Rust unit measurement, not a
traffic test, and the forwarding path moves enough here that a real DUT run is
owed before merge. A green smoke would be evidence about the reference topology
and the forwarding path; it is NOT evidence about B1/C1/C2/C3 — the tests are,
because none of those four shapes appears in the shipped cluster config.

Two unrelated flakes seen once each under full-suite parallelism and not
reproducible: pkg/ddns TestRFC2136UpsertNeverSendsDeleteRRset (3/3 green on
re-run) and slowpath::tests::enqueue_refuses_frame_above_live_mtu ("slow-path
worker is not running", 3/3 green on re-run). Neither file is touched by this
PR.
@psaab
psaab force-pushed the fix/6713-xfrmi-tozone branch from b556066 to f7c8ce7 Compare August 13, 2026 23:37
@psaab

psaab commented Aug 13, 2026

Copy link
Copy Markdown
Owner Author

Cluster smoke at f7c8ce7bb: 14 passed, 0 failed, rc=0

Failover test: 14 passed, 0 failed
iperf3 throughput: 20.1 Gbps (>= 1.0 Gbps)
fw0 has 10 established sessions / fw1 has 10 synced sessions
v4 push 7.89 Gbits/sec  0 retr   |  v4 rev 6.64 Gbits/sec  0 retr
test-failover rc=0

Sysrq crash of fw0, failover to fw1, rejoin as secondary, manual failback — all green.

Pushed-and-running xpfd 643045fb6d5b3b6e81243e5b9cddc68cbc8614312aaa75af2005759c1818c4c7,
identical on both nodes and different from the f0ab2bc4… this PR ran at b5560662b
so the deploy took and this run exercised the round-10 code rather than carrying the
earlier certification forward.

Two corrections this round produced, both against things I had said

1. The fail-closed wording was wrong, and it was mine by adoption. I asked for "the
helper still forwarding its previous-good image after the abort". Measured against a
recording helper, that is not what happens here: ErrEgressZoneProtocolIncompatible is a
required-protocol sentinel, so the control plane refuses before publishing and a real
set_forwarding_state{Armed:false} reaches the helper. No apply_snapshot is sent; the
deferred worker-arm debt survives. Transit falls to the kernel path.

A bump with no gate would have been the keep-forwarding shape. This gate deliberately
trades that availability for a legible refusal. Opposite availability profiles, so the
distinction is not cosmetic.

The same sentence went to #6691, which refused it for a different reason — there the two
halves belong to two different paths. Between the two measurements:

path helper commit
plain version mismatch refuses the snapshot, stays armed on previous-good, keeps forwarding fails via the deferred path
capability gate arms deliberately disarmed aborts early

Corrected in the commit message, _Log.md and docs/userspace-dataplane-architecture.md.

2. The fixture-migration proof I specified could not be produced — and that is the
finding, not a shortfall.
I asked for 2-3 of the 54 migrated fixtures shown still RED
under a consumer mutation. Measured: they stay green. Not because the stamp weakened
them, but because for a single-identity ifindex the ledger's answer and the row's own zone
are the same value, so no mutation swapping one for the other is observable there. Those
fixtures bind that a zone reaches the policy decision; they never bound which mechanism
supplied it, before the migration or after.

The property was then proven a better way — the same mutation against the pre-migration
tree, failure sets compared:

tree tests the consumer mutation reds
pre-migration unzoned_iface_tunnel_unit_..._via_egress_row_6722
post-migration unzoned_iface_tunnel_unit_..._via_egress_row_6722

Identical. The migration removed no binding. A post-migration count alone would have been
a number with nothing to compare it to.

And the comparison exposed a gap neither of us was looking for: the only discriminator
asserts a 0 sentinel, so B1's positive direction — the ledger's non-zero answer
beating a dissenting row — had no cell at all.
egress_row_zone_is_order_invariant_not_last_write_6722 closes it, RED under the mutation
at left: 0, right: 1, constructed by reversing emission order so last-write-wins reads 0
where the ledger reads lan.

Mutual distinguishability on the fail-closed cells

mutation requests the helper saw reds on
gate relocated AFTER apply_snapshot [apply_snapshot status set_forwarding_state{armed:false}] ordering
disarm dropped, gate kept [status] disarm

Two one-line edits, two different outcomes, two different assertions — neither cell's RED
can be the other's. That rules out a stale artifact rather than merely making it unlikely.

@psaab

psaab commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

Hostile Claude at f7c8ce7bb: MERGE-NEEDS-MAJOR — two unbound fail-opens, each with a runnable repro.

Both are the "guard IS the deliverable" shape: the runtime is correct at this head, but
the two fail-closed decisions this PR exists to make can be deleted and both suites certify
the tree. Neither needs a code change — each needs a cell.

B1 — interfaces.go:631-632, rule 2's conflict arm, fails OPEN on the STRICT path

case len(st.authored) > 1:
    zone = ""

Mutating it to pick one authored zone instead of none leaves the whole Go suite green.

set interfaces ge-0/0/1 gigether-options redundant-parent reth1
set interfaces reth1 redundant-ether-options redundancy-group 1
set interfaces reth1 unit 0 family inet address 10.0.61.1/24
set security zones security-zone lan interfaces reth1
set security zones security-zone wan interfaces ge-0/0/1
unmutated : ifindex 24 [ge-0/0/1 zone=wan | reth1 zone=lan | reth1.0 zone=lan] -> egress_zone ""    PASS
mutated   : same rows                                                           -> egress_zone "wan" FAIL

This compiles cleanly under strict CompileConfigvalidateRethMemberStrict does not
reject an explicitly zoned member — so it is reachable through an ordinary operator commit,
not only the tolerant path. And the Rust corroboration structurally cannot catch it: the
ge-0/0/1 row literally carries wan, so carried.contains("wan") succeeds. The reth's
LAN transit would be adjudicated in wan, nondeterministically between lan and wan
across restarts
given Go map iteration order.

TestContestedNetdevOwnershipFailsClosed_6722 binds rule 1 (egressIdentitiesCohere), not
this arm.

B2 — interfaces.go:728, the reth-prefix conjunct, fails OPEN on exactly the path it claims to hold

if ifc == nil || strings.HasPrefix(name, "reth") {

Dropping the conjunct leaves the whole Go suite green.

set interfaces reth1 gigether-options redundant-parent reth0
set interfaces reth0 redundant-ether-options redundancy-group 1
set interfaces reth0 unit 0 family inet address 10.0.61.1/24
set security zones security-zone lan interfaces reth0
unmutated : ifindex 24 [reth0 zone=lan | reth0.0 zone=lan | reth1 zone=""] -> egress_zone ""    PASS
mutated   : same rows                                                       -> egress_zone "lan" FAIL

interfaces.go:740-743 says this clause is what "holds the line" on the tolerant-load /
peer-sync path where validateRethMemberStrict is downgraded to a warning (#1960). It
fails open on precisely that path. The three existing reth-clause tests all exercise the
commit gate; the one lenient-path cell uses st0, where the prefix clause is
irrelevant. The clause only bites for a unit-less reth — one with units is caught by the
firstConfiguredUnit conjunct — which is why no fixture reaches it.

The reviewer corrected my baseline, and then retracted one of its own findings

My baseline was wrong. I had treated c9b020695 as the pre-migration tree. It predates
the wire field entirely (grep egress_zone …/snapshot.rs returns nothing there). The true
pre-migration tree is b5560662b. Re-run against it:

b5560662b (pre)   FULL_RC=101  4276 passed; 1 failed
f7c8ce7bb (post)  FULL_RC=101  4275 passed; 2 failed   (the same one + the new order-invariance cell)

Pre ⊆ post — the migration removed no binding and added one. A second, disjoint consumer
mutation (deleting carried.contains(zone)) reds a different single test, so neither
mutation is carrying the other.

And the retraction. It had listed forwarding_build/interfaces.rs:78
(Self::Conflicting => None) as a survivor, then withdrew it: carried for that fixture is
{"", "lan"}, so .iter().next() returns "", which zone_name_to_id never holds — the
mutation reduced to unmutated behaviour and the green was meaningless.
next_back() reds
conflicting_egress_zone_claims_on_one_ifindex_fail_closed_6722. That is the discipline I
want on every green cell: ask whether the mutation actually changes the value on the path
under test before reporting a survivor.

Claim defects — N1 is the notable one

protocol.go:307-331 carries a paragraph asserting the opposite of what this PR does, in
the file whose own constant contradicts it: it says Rust decodes Option<String> with an
absent-value fallback (the field is String and the compat arm was deleted this round); it
says "ConfigSnapshotProtocolVersion stays at 4" while protocol.go:61 sets 5; it cites
old_go_wire_shape_into_new_helper_fails_closed_6722, a test that does not exist anywhere
in the tree
; and it says both directions keep blackholing during a mixed window, which the
gate contradicts by aborting and disarming.

N4 is load-bearing in a different way: types/forwarding.rs:634 cites
buildUserspaceIngressInterfaces, which does not exist. The claim is substantively true —
the real guards are UserspaceBoundLinuxInterfaces (interfaces.go:164) and
buildUserspaceIngressIfindexes (maps_sync.go:1592) — but it is the evidence for the
ingress-unreachability half of the asymmetry justification, so its support being fictional
matters.

N5 has a measured behaviour change behind it: manager_status.go:105-107 and
manager_ha.go:625-626 both say the gate "is a no-op unless the last-applied config
requires the protocol", but with lastStatus=4 and an empty Config{}, both
SetForwardingArmed(true) and syncDesiredForwardingStateLocked() return
ErrEgressZoneProtocolIncompatible. Fail-closed and defensible, untested at both sites, and
both comments now assert the opposite.

Sweep

39 valid cells, 24 RED, 15 green. Two survivors change behaviour (above); the rest have no
constructible behaviour change or are provably inert, and are recorded on the PR so a later
round does not re-walk them. Two cells broke the build and were reported void, not green.
The three wg::engine hangs and the shared_cos_lease timing cell were discarded as
contention artefacts rather than reported — the latter is #7001.

Round 11 dispatched.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant