Skip to content

config: compile the compact-leaf security-zone interfaces stanza - #6735

Open
psaab wants to merge 10 commits into
masterfrom
fix/6525-zone-compact-leaf
Open

config: compile the compact-leaf security-zone interfaces stanza#6735
psaab wants to merge 10 commits into
masterfrom
fix/6525-zone-compact-leaf

Conversation

@psaab

@psaab psaab commented Aug 2, 2026

Copy link
Copy Markdown
Owner

The defect

pkg/config/compiler_security_zones.go, case "interfaces": iterated
prop.Children and never read prop itself. In the hierarchical COMPACT-LEAF
spelling the member name lands on the stanza's own Keys[1] with nil
Children, so

security { zones { security-zone untrust { interfaces ge-0/0/1.0; } } }

ran the loop body zero times and the zone compiled with no interfaces
cleanly, no error, no warning. Both strict zone gates
(validateZoneInterfaceMembershipStrict, validateZoneInterfaceDefinedStrict)
then passed vacuously over the empty slice: the gates that exist
specifically to protect zone membership were inert against a membership list
that never arrived. Downstream UserspaceBoundLinuxInterfaces
(pkg/dataplane/userspace/interfaces.go) skips any interface with Zone == "",
so the interface was never AF_XDP-bound and every policy naming that zone never
applied to its traffic.

The with-body variant was worse than a drop: prop.Children there holds the
member's body, so the loop ran once with the host-inbound-traffic node
mistaken for a member — the real member dropped and its body keywords
compiled as phantom interface names.

Measured differential (compileZones, at the parent SHA)

variant BLOCK spelling COMPACT-LEAF spelling
single [ge-0/0/1.0] []
multi [ge-0/0/0.0 ge-0/0/1.0] []
with body [ge-0/0/0.0] + hib[ge-0/0/0.0]={ssh} [host-inbound-traffic system-services ssh], no override

Reachability (honest bound)

Hierarchical text ingest only — load override / load merge / the persisted
config file / HA SyncApply. Not reachable from the set CLI (SetPath
always descends the interfaces container and stores each member below it —
pinned by TestZoneInterfaces6525FlatSetNeverReachesCompactLeaf), and
show configuration | display set round-trips safely. Those are still the boot
path and the peer-sync path.

The fix

zoneInterfaceMemberNodes normalizes the compact shape onto the block
shape: it synthesizes one member node carrying prop.Keys[1:] with
prop.Children as that member's body. Every spelling then takes the identical
code path, so membership, the #5248 bracket flatten and the #6391 Keys-scoped
host-inbound override stay in one implementation — a second read path is
exactly how this defect class arises.

Note the slice. The fix proposed in the issue is wrong as written: passing
prop straight to zoneInterfaceMembers (whose Keys loop starts at index 0,
correct for a child) compiles a zone member literally named interfaces. That
is mutation-proven below, and an unrelated pre-existing test catches it too.

zoneInterfaceMembers now also truncates a member's Keys at a body keyword and
stops recursing there, so the fix cannot trade a silent drop for a silent
invention. That additionally corrects the pre-existing block-form
behaviour for the hierarchical PACKED spelling
interfaces { a host-inbound-traffic system-services ssh; }, which was already
compiling three phantom members.

Fail-closed belt

validateZoneInterfacesNonEmptyStrict rejects an interfaces stanza that
carries content yet contributes zero members, so any future shape the
compiler cannot read is loud instead of silent. It asks the compiler's own
reader
(zoneInterfaceStanzaMembers) rather than re-deriving membership from
the AST. That is deliberate, and it is the lesson of this defect class: a gate
carrying its own second derivation of what a stanza names can drift from the
compiler and go vacuous exactly when it matters — which is precisely how the two
existing zone gates ended up walking an empty slice here. Sharing one reader
makes the gate structurally incapable of disagreeing with what actually got
compiled. It runs on the group-expanded *ConfigTree rather than the compiled
*Config because a compiled ZoneConfig cannot distinguish "no stanza" from
"a stanza that compiled to nothing".

strict vs warn: strict on commit / commit-check, downgraded to a
cfg.Warnings entry on the tolerant load / peer-sync paths
(lenientZoneInterfacesNonEmpty) — verbatim the convention of its two
neighbours in the same gate block and the #1960 no-brick doctrine. On the
tolerant path behaviour is unchanged (the stanza contributed no members before
this gate existed and still contributes none), just with an operator-visible
warning.

The gate is content-sensitive rather than a blanket "declared but empty"
check, deliberately: delete security zones security-zone <z> interfaces <if>
of the last member leaves the now-empty container behind (deletePath removes
the member node but does not prune the container), so a blanket check would make
an ordinary edit uncommittable against a stanza that renders invisibly — the
#4191 over-rejection class. Mutation M7 below proves that control binds.

The #6391 separation is intact

compileZones still scopes the per-interface host-inbound override on the
member node's own Keys (zoneInterfaceMemberKeys) and never on its
children. TestZoneInterfaces6525OverrideStaysScopedToItsOwnMember proves an
override authored for a does not reach b, in both the hierarchical
nested-member shape and the exact #6389 flat-set config
(set ... interfaces [ a b ] then
set ... interfaces a host-inbound-traffic ... ssh). Mutation M5 widens that
scope and fires six pre-existing #6391 sibling-leak guards plus the new test.

Mutation evidence

Every mutation applied by Edit (never git checkout), each with go build ./... and go vet clean and a real assertion failure, restored by
Edit; unrelated tests green throughout.

# mutation assertion text
M1 normalization → for _, iface := range prop.Children compact-leaf "interfaces ge-0/0/1.0;" compiled zone membership [], but the block spelling "interfaces { ge-0/0/1.0; }" compiled [ge-0/0/1.0] (+ CompileConfig accepted a compact-leaf zone member naming an UNDEFINED interface; the strict zone-interface-defined gate passed vacuously over an empty member set)
M2 the issue's own fix: Keys: prop.Keys (not sliced) compiled zone membership [interfaces ge-0/0/1.0], but the block spelling … compiled [ge-0/0/1.0]; unrelated TestHostInbound3703HierarchicalBlockShape also reds with references interface "interfaces"
M3 body-keyword truncation removed stanza "interfaces ge-0/0/0.0 host-inbound-traffic system-services ssh;" compiled zone membership [ge-0/0/0.0 host-inbound-traffic system-services ssh], want [ge-0/0/0.0] (block form too)
M4 body-on-Keys no longer stops child recursion reject error "… references interface \"system-services\" …" is not the #6525 non-empty gate
M5 override scope zoneInterfaceMemberKeyszoneInterfaceMembers per-interface host-inbound = map[ge-0/0/0:… ge-0/0/1:…], want map[ge-0/0/0:…] (sibling leak?) — six pre-existing #6391 guards + the new test
M6 non-empty gate call removed CompileConfig accepted a zone whose 'interfaces' stanza (interfaces host-inbound-traffic;) names no interface + tolerant path produced no 'zone interfaces non-empty' warning
M7 over-rejection carve-out removed (EDGE) CompileConfig rejected a config whose zone lost its last interface via 'delete': … — the non-empty gate must not fire on a stanza that declares nothing
M8 both differential sides collapsed (EDGE) block spelling "interfaces { ge-0/0/1.0; }" compiled 0 members [], want 1 — the differential's reference side is itself broken — the count floor binds, the equality does not pass vacuously

The differential derives its expectation from the block spelling rather than
a hardcoded list, with a per-case member-count floor so a both-empty pair cannot
pass (M8 proves the floor).

Four pre-existing tests were relying on the silent drop

TestHostInboundDupBlock4544{ZoneMerges,MergeDedups,SingleBlockUnchanged} and
TestNat66SourceRules use the compact-leaf spelling and never define the
interfaces they name. They only compiled because the members never reached the
defined gate. They now carry the missing interfaces definitions.

Validation

go build ./... + go vet ./... clean. Full Go suite green (59 packages, exit
0). New test names confirmed present in -v output under a fresh GOCACHE
(8 top-level, 19 subtests). No cluster/deploy tooling was run.

Docs

docs/config-schema.md gains a "The COMPACT-LEAF spelling…" section between the
#5248 and #6391 sections (shape table, both traps, the gate and its
over-rejection carve-out, the reachability bound); pkg/config/README.md gains
the general Gotchas rule — a stanza's members can live on the stanza's own Keys,
not only in its children.

Adjacent gap filed separately

The hierarchical packed per-interface host-inbound body
(interfaces ge-0/0/0.0 host-inbound-traffic system-services ssh;) still does
not compile into an override — same #2419 packed-body class, different
dimension, fail-CLOSED. Filed as its own issue rather than folded here.

Closes #6525

🤖 Generated with Claude Code

https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi

`compileZones` iterated `prop.Children` for the `interfaces` stanza and
never read `prop` itself. In the hierarchical COMPACT-LEAF spelling the
member name lands on the stanza's own `Keys[1]` with nil `Children`, so

    security { zones { security-zone untrust { interfaces ge-0/0/1.0; } } }

ran the loop body ZERO times and the zone compiled with NO interfaces --
cleanly, no error, no warning. Both strict zone gates
(validateZoneInterfaceMembershipStrict, validateZoneInterfaceDefinedStrict)
then passed VACUOUSLY over the empty slice: the gates that exist
specifically to protect zone membership were inert against a membership
list that never arrived. Downstream, UserspaceBoundLinuxInterfaces skips
any interface with `Zone == ""`, so the interface was never AF_XDP-bound
and every policy naming that zone never applied to its traffic.

The with-body variant was worse than a drop. `prop.Children` there holds
the member's BODY, so the loop ran once with the `host-inbound-traffic`
node mistaken for a member: the real member was dropped AND its body
keywords were compiled as phantom interface names
(`[host-inbound-traffic system-services ssh]`).

Reachability, honest bound: hierarchical text ingest only -- `load
override`, `load merge`, the persisted config file, HA SyncApply. `set`
cannot reach it (SetPath always descends the `interfaces` container) and
`show configuration | display set` round-trips safely. Those are still
the boot path and the peer-sync path.

Implementation. zoneInterfaceMemberNodes NORMALIZES the compact shape
onto the block shape -- it synthesizes one member node carrying
`prop.Keys[1:]` with `prop.Children` as that member's body -- so every
spelling takes the identical code path and membership, the #5248 bracket
flatten and the #6391 Keys-scoped host-inbound override stay in ONE
implementation. A second read path is exactly how this defect class
arises. Note the slice: passing `prop` straight to zoneInterfaceMembers
(whose Keys loop starts at index 0, correct for a CHILD) would compile a
zone member literally named `interfaces`.

zoneInterfaceMembers now also truncates a member's Keys at a body keyword
and stops recursing from there, so the fix cannot trade a silent DROP for
a silent INVENTION. That also corrects the pre-existing block-form
behaviour for the hierarchical PACKED spelling `interfaces { a
host-inbound-traffic system-services ssh; }`, which was already compiling
three phantom members. The packed body is not parsed into a per-interface
override -- a separate gap in the same #2419 packed-body class, filed on
its own; that direction is fail-CLOSED (the override is absent, so the
interface admits only what the zone level admits).

Fail-closed belt: validateZoneInterfacesNonEmptyStrict rejects an
`interfaces` stanza that CARRIES CONTENT yet contributes zero members, so
any future shape the compiler cannot read is loud instead of silent. It
asks the compiler's own reader (zoneInterfaceStanzaMembers) and runs on
the group-expanded *ConfigTree, because a compiled ZoneConfig cannot
distinguish "no stanza" from "a stanza that compiled to nothing". Strict
on commit / commit-check, downgraded to a warning on the tolerant load /
peer-sync paths (lenientZoneInterfacesNonEmpty) per the #1960 no-brick
doctrine, matching its two neighbours in the same gate block.

The gate is content-sensitive rather than a blanket "declared but empty"
check, deliberately: `delete security zones security-zone <z> interfaces
<if>` of the LAST member leaves the now-empty container behind
(deletePath removes the member node but does not prune the container), so
a blanket check would make an ordinary edit uncommittable against a
stanza that renders invisibly -- the #4191 over-rejection class. A
mutation confirms that control binds.

Four pre-existing tests were relying on the silent drop: their configs
use the compact-leaf spelling and never define the interfaces, which only
compiled because the members never reached the defined gate. They now
carry the missing `interfaces` definitions.

Validation: `go build ./...` and `go vet ./...` clean; full Go suite
green (59 packages). Eight mutations, each with build+vet CLEAN and a
real assertion failure: the normalization reverted to `prop.Children`;
the issue's own proposed fix (`prop.Keys` unsliced, which compiles a
member named `interfaces` -- caught by an unrelated pre-existing test
too); the body-keyword truncation removed; body-on-Keys no longer
stopping child recursion; the override scope widened from
zoneInterfaceMemberKeys to zoneInterfaceMembers, which fires six
pre-existing #6391 sibling-leak guards; the gate call removed; the
over-rejection carve-out removed; and an edge mutation collapsing BOTH
sides of the differential, which trips the count floor rather than
passing vacuously. New test names confirmed present in `-v` under a fresh
GOCACHE.

Advances #6525.
@psaab

psaab commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Independent review at this head (2bf2ef846): MERGE-NEEDS-MAJOR

Two blocking findings, plus a large pre-existing cohort this review surfaced.

B1 — the truncation silently drops valid members after a body keyword in a bracketed list

zoneInterfaceKeysBeforeBody returns at the first body keyword and discards every remaining key in that slice:

for _, k := range iface.Keys {
    if k == "" { continue }
    if zoneInterfaceBodyKeywords[k] { return names, true }
    names = append(names, k)
}

Because the lexer strips brackets, a bracketed list collapses every member onto one Keys slice. So:

security-zone Z {
    interfaces [ ge-0/0/0.0 host-inbound-traffic ge-0/0/1.0 ];
}

compiles the member set as [ge-0/0/0.0] alone. The malformed middle token is hidden from the defined-interface gate, and ge-0/0/1.0 — a valid, defined interface the operator placed in this zone — is silently dropped, left outside the zone and outside policy enforcement. The new non-empty belt does not fire, because one member survived.

All three readers agree here (the compiler and both strict validators consume the same compiled zone.Interfaces slice, and the new belt deliberately routes through the compiler's own reader), which is exactly why none of them catches it. Agreement is the right design; it just means the truncation defect is uniform rather than divergent.

Related, same root: the schema makes the interface name a wildcard, so host-inbound-traffic is syntactically accepted in the member slot as well as being the body keyword. The truncator cannot distinguish the two.

B2 — none of the new tests binds the fix

Every test added or changed by this PR still passes under a meaningful reversion of the production change:

  • TestZoneInterfaces6525VacuousStanzaAccepted passes with the new non-empty gate removed entirely — success is the pre-gate default, so the test constrains nothing.
  • TestZoneInterfaces6525EmptyStanzaRejected passes if zoneInterfaceStanzaMembers always returns nil; there is no non-empty control.
  • TestZoneInterfaces6525FlatSetNeverReachesCompactLeaf passes vacuously if SetPath stops emitting the interfaces property at all — the loop simply never finds a node, and nothing asserts that it did.
  • TestZoneInterfaces6525CompactLeafNeverInventsMembers expects wantHIB: nil, which is the failure default of not parsing the packed body.
  • The 4544 host-inbound tests and TestNat66SourceRules all still pass with the member loop reverted to prop.Children; they assert zone-level host-inbound content or NAT rules, never the membership this PR fixes.

And no test covers [ valid-a host-inbound-traffic valid-b ], which is B1.

Non-blocking (fix the wording inline)

The comment at the flat-set description and docs/config-schema.md both say the set-produced shape has "one child per member". A flat bracket command actually produces interfaces → a(container) → leaf Keys=[b,c]. The recursion handles it correctly; the description is what is wrong.

Confirmed sound

The body-keyword list is complete: the schema gives an interface member exactly one direct body keyword, host-inbound-traffic, and zoneInterfaceBodyKeywords contains exactly that. No missing truncators, no over-broad ones.

Cohort surfaced — filed separately, not this PR's problem

The review enumerated roughly thirty other prop.Children-only arms across the config compiler with this identical shape. Two are security-relevant and are now filed with firsthand verification: #6817 (a login user's authentication compact spelling drops the credential) and #6818 (OSPF interface authentication compact spelling drops authentication, so the adjacency forms unauthenticated). The remainder — BFD modifiers, DHCP static bindings, RPM thresholds, SNMPv3 auth/privacy bodies, syslog transport, RA prefix flags — will be filed as their own issues. None of them is in scope here.

Paul Saab and others added 2 commits August 5, 2026 09:40
`zoneInterfaceKeysBeforeBody` truncates a member's Keys at the first body
keyword and discards everything after it. The lexer strips brackets
(#2419), so these two statements reach the compiler byte-identical:

    interfaces [ ge-0/0/0.0 host-inbound-traffic ge-0/0/1.0 ];
    interfaces ge-0/0/0.0 host-inbound-traffic system-services ssh;

and their readings DISAGREE about zone membership. The first is a bracket
member list; truncation silently drops `ge-0/0/1.0` — a valid, defined
interface the operator placed in this zone — leaving it with Zone == "",
so UserspaceBoundLinuxInterfaces never AF_XDP-binds it and no policy
naming the zone applies to its traffic. The second is the packed body;
truncation silently drops the whole host-inbound override. The #6525
non-empty belt cannot catch the first: one member survived, so the stanza
is not empty, and all three readers agree because they consume the same
compiled zone.Interfaces.

The ambiguity is irreducible at this layer — the punctuation that would
tell the two apart is gone before the compiler sees the node — so this
resolves it by REFUSING rather than guessing.
validateZoneInterfacePackedTailStrict hard-rejects at commit and
downgrades to a cfg.Warnings entry on the tolerant load / peer-sync path
(#1960 no-brick), naming the zone, the keyword and the exact tokens that
would have been dropped, and pointing at the block spelling, which is
unambiguous and fully supported.

Deliberate consequence rather than collateral: the packed-body spelling
used to COMMIT, contributing membership while silently discarding an
authored host-inbound directive (the fail-closed residual #6525 left
open). Quietly dropping a security directive is the class #6525 exists to
close, so making it loud is the intent. A body keyword with NOTHING after
it stays accepted — truncation is lossless there, and rejecting it would
be the #4191 over-rejection class.

The gate runs BEFORE the non-empty gate. They overlap on exactly one
shape, `interfaces host-inbound-traffic ge-0/0/1.0;`, where the keyword is
first so nothing precedes it and the stanza also compiles to zero members.
Both would fire; this one's message is the accurate one, because telling
an operator who plainly wrote ge-0/0/1.0 that the stanza "names no
interface" sends them hunting the wrong defect.

REACHABILITY CORRECTION. #6525 documented this defect class as
hierarchical-ingest only — "NOT reachable from the `set` CLI". True of the
compact-leaf shape, FALSE of this one. Measured:

    set security zones security-zone Z interfaces \
        [ ge-0/0/0.0 ge-0/0/1.0 host-inbound-traffic ge-0/0/2.0 ]

    interfaces
      ge-0/0/0.0
        Keys=["ge-0/0/1.0","host-inbound-traffic","ge-0/0/2.0"]

SetPath descends the interface-name wildcard for the first token and
collapses the rest onto ONE nested leaf, keyword included, so the
truncator runs on a nested member and ge-0/0/2.0 is dropped from the
ordinary operator CLI. Pinned by
TestZoneInterfaces6735FlatSetReachesThePackedTail, which is also what
binds the detector's child recursion — measured, with the recursion
deleted every other case still passes, because the stanza-level loop
already checks each block member's own Keys.

Validation. Two mutations, both RED from an ASSERTION with `go build` and
`go vet` CLEAN under the mutation, GREEN on restore:

  M1 detector never reports a tail
     -> TestZoneInterfaces6735PackedTailRejectedAndPositiveControl
        "CompileConfig ACCEPTED the ambiguous stanza ..." (3 subtests)
  M2 child recursion removed
     -> TestZoneInterfaces6735FlatSetReachesThePackedTail
        "detector missed a `set`-authored packed tail"

The reject and accept tables live in ONE test function on purpose: a
rejection test alone cannot distinguish "rejects the ambiguous statement"
from "rejects everything", and keeping them together means neither can be
removed without the other.

Advances #6525.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
Gate finding B2 said every test added by #6735 still passes under a
meaningful reversion. Verified each claim rather than accepting it; the
headline one is wrong and two are right.

WRONG, measured. Reverting `zoneInterfaceMemberNodes` to the pre-fix
`return prop.Children` — the core #6525 fix — fails NINE tests with
`go build` and `go vet` clean:

    TestHostInbound3703HierarchicalBlockShape
    TestZoneInterfaces6525CompactLeafMatchesBlock
    TestZoneInterfaces6525CompactLeafNeverInventsMembers
    TestZoneInterfaces6525OverrideStaysScopedToItsOwnMember
    TestZoneInterfaces6525StrictGatesNoLongerVacuous
    TestHostInboundDupBlock4544ZoneMerges
    TestHostInboundDupBlock4544MergeDedups
    TestHostInboundDupBlock4544SingleBlockUnchanged
    TestNat66SourceRules

including the 4544 host-inbound tests and TestNat66SourceRules, which the
finding named as passing under exactly that reversion. The core fix is
bound; the differential in CompactLeafMatchesBlock is what binds it.

RIGHT, and fixed:

  - TestZoneInterfaces6525EmptyStanzaRejected had no positive control. A
    `zoneInterfaceStanzaMembers` that always returned nil makes EVERY
    stanza look empty, so the reject table passes while the gate rejects
    everything. Measured: that mutation builds clean, vets clean, and the
    test passed under it. Adds a positive-control subtest — three stanzas
    that name a defined interface and must still compile with a non-empty
    member set. It now goes RED under that mutation.

  - TestZoneInterfaces6525FlatSetNeverReachesCompactLeaf asserted inside
    two filtered loops, so if SetPath ever stopped emitting an
    `interfaces` property the body never ran and the test passed having
    asserted nothing. Adds an `examined` counter and requires at least one
    stanza. It now goes RED when the filter matches nothing.

Both are proven by mutation, RED from an assertion, GREEN on restore.

Also corrects a description that was simply wrong, in the code comment
and in docs/config-schema.md: a flat bracket list does NOT produce one
child per member. Empirically —

    set security zones security-zone Z interfaces [ a b c ]

    interfaces   Keys=["interfaces"]  children=1
      a          Keys=["a"]           children=1
        b c      Keys=["b","c"]       children=0

The schema models the interface name as a wildcard CONTAINER, so SetPath
descends it for `a` and collapses the remainder onto one leaf beneath it.
`zoneInterfaceMembers` recurses and reads every key at each level, so the
members are read correctly and this was a description bug, not a code bug
— but the wrong description is exactly the mental model that produces a
one-level `prop.Children` reader, which is the #6525 defect itself. Pinned
by TestZoneInterfaces6735FlatSetBracketNestsRatherThanFanning.

The #6525 reachability bound is narrowed to the shape it actually
describes, since the sibling packed-tail shape IS reachable from `set`.

Advances #6525.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
Paul Saab and others added 7 commits August 5, 2026 10:50
The packed-tail gate runs before the non-empty gate deliberately: they
overlap on exactly one shape, `interfaces host-inbound-traffic
ge-0/0/1.0;`, where the keyword is first so nothing precedes it and the
stanza also compiles to zero members. The packed-tail message names the
token that was dropped; "names no interface" is true but sends an operator
who plainly wrote ge-0/0/1.0 hunting the wrong defect.

Nothing tested that. Both gates return a non-nil error for the overlap
shape, so swapping them left every test green and silently handed the
operator the worse message — an ordering rationale with no guard behind
it, which is how it decays into someone "simplifying" the two gates into
one.

Asserting the three tokens does not separate them either: the non-empty
message renders the same zone, keyword and dropped token through
zoneInterfaceStanzaTokens, so the pre-existing assertion in the reject
table matched both messages equally.

Each gate now declares the distinguishing clause of its own message as a
named constant — zoneInterfacePackedTailReason and
zoneInterfacesNonEmptyReason — used verbatim in that message. The rendered
text is byte-identical; the point is that a test can assert WHICH gate
rejected rather than matching prose a later reword would silently unbind.

TestZoneInterfaces6735OverlapShapeReportsThePackedTailGate asserts both
directions. The overlap shape must carry the packed-tail reason and NOT
the non-empty one; a genuinely empty stanza (`interfaces
host-inbound-traffic;`, a body-only block) must carry the non-empty reason
and NOT the packed-tail one. Without the second half, "make the
packed-tail gate fire on everything" would satisfy the first.

Proven by swapping the two gate invocations: RED on the reason assertion
with `go build` = 0 and `go vet` = 0 — both orders compile, so this is an
assertion failure and not a build break — and GREEN on restore.

Also fixes two comments that contradicted the shape this PR's own tests
pin. Both described SetPath as storing "each member below it", implying a
flat fan-out; TestZoneInterfaces6735FlatSetBracketNestsRatherThanFanning
proves a flat bracket list NESTS into a chain. The load-bearing claim in
both places is narrower — the STANZA node's Keys stay exactly
["interfaces"] — so both now assert that and explicitly disclaim any
statement about how the members are arranged below it. Since this PR is
what established that shape empirically, the comments follow the tests.

And repairs structure this branch broke earlier: moving the packed-tail
gate ahead of the non-empty gate left the non-empty comment block stranded
above the packed-tail comment with its own code far below it. Each gate's
comment now sits with its own code.

Advances #6525.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
The positive-control table asserted compiled MEMBERSHIP only. Two of its
cases carry a `host-inbound-traffic` body, and those are exactly the
spellings the packed-tail reject message tells the operator to rewrite
INTO. So the table would have stayed green while the block-spelling
override silently vanished — it accepted the one regression that would
make the reject message's own advice actively harmful, sending operators
to a spelling whose body no longer compiled.

Each accept case now asserts the compiled per-interface override as well.
`wantHIB` is nil for `interfaces ge-0/0/0.0 host-inbound-traffic;`, which
is the documented fail-CLOSED #6525 residual (a packed body is not parsed
into an override) rather than an oversight, and stating it as an
expectation keeps that distinction visible instead of implicit.

Three mutations added so every assertion introduced by this branch has a
distinguishing one. All RED from an assertion, with `go build` = 0 and
`go vet` = 0 under the mutation, GREEN on restore:

  M5 gate fires regardless of tail
     -> accept table: "REJECTED the unambiguous stanza
        `interfaces ge-0/0/0.0 host-inbound-traffic;`" (#4191 direction)
  M6 override fans to no member
     -> accept table: "scoped the per-interface host-inbound override to
        [], want [ge-0/0/0.0]" — the assertion added here
  M7 packed-tail gate strict on the tolerant path
     -> "CompileConfigLenient rejected a packed-tail stanza" (#1960)

Note on the technique this branch settled on, since it generalizes: the
ordering fix works by giving each gate a named reason constant used
VERBATIM in its own message. Rendered operator-visible text is
byte-identical, so nothing an operator reads changed and every
pre-existing assertion still matches — but a test can now assert WHICH
gate rejected, binding gate identity rather than prose that a later
reword would silently unbind. That is the reusable shape for any pair of
gates whose messages can both plausibly match the same input.

Advances #6525.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
The r4 log entry claimed three mutations gave "every new assertion" a
distinguishing one. That was false. This round measures per-mutation
failure sets instead of restating the claim, corrects the sentence, and
closes the one test-acceptance gap the gate found.

DEFINITION, so the claim stays checkable. A mutation DISTINGUISHES an
assertion when, with that single production edit applied, the failure set
across the whole pkg/config package is that assertion ALONE. A mutation
that also reds assertions predating it proves those older assertions
still work; it proves nothing about the new one.

Measured (every one: go build ./... = 0, go vet ./... = 0, RED from an
assertion, GREEN on restore):

  M5  gate ignores the tail                  10 failing  distinguishes nothing
  M6  override fans to no member             35 failing  distinguishes nothing
  M7  packed gate strict on tolerant path     1 failing  the lenient warning
  A5  gate fires when keyword is not FIRST    1 failing  keyword-last accept row
  ORD swap the two gate invocations           1 failing  the gate ORDER
  A6  override compiles keyed-but-EMPTY      26 failing  distinguishes nothing

M5 reds the pre-branch TestZoneInterfaces6525EmptyStanzaRejected; M6 reds
#6391 x9 plus #5248/#3362/#3703/#4544/#4818/#3226/#4455. Neither says
anything about an assertion added here. A5 replaces M5 as the citation
for the keyword-last accept row — same file, one condition narrower, and
its failure set is that row alone. M6 has no replacement because none
exists.

So THREE assertions this branch adds are independently binding, each with
a named edit that reds it and nothing else: the tolerant-path warning,
the keyword-last accept row, and the gate order. The rest corroborate.

The accept table asserted override KEYS, which accepts an override whose
value is nil or empty — the failure default of that path, so it was green
for exactly the regression it names. It now asserts member -> admission
TOKENS via hibTokens6735, and one row carries a multi-token body
([ ssh ping ] + protocols ospf) fanned across two bracket members, so a
fan keeping only the first service and a clone sharing a backing store
both fail it. The empty-value edit reds 26 assertions against the
key-only form and 30 against the token form; the +4 are exactly the two
body-carrying rows. All 26 are pre-branch, so this assertion still does
not BIND the override — the struct comment now says so rather than
implying otherwise.

Third comment this PR that its own tests disprove: the packed-tail gate
said "a keyword with nothing after it, or a body-only block, leaves this
gate silent". A body-only block is not silent by virtue of being
body-only. `interfaces { host-inbound-traffic system-services ssh; }`
carries `system-services ssh` on the keyword's OWN Keys, so it is a tail
and the gate FIRES; only the nested-block spelling
`interfaces { host-inbound-traffic { system-services ssh; } }` stays
silent and falls through to the non-empty gate. The comment now names
nested-block, and the same-Keys spelling is a new reject-table row so the
corrected text is pinned rather than asserted.

Also recorded in the overlap test that it observes rendered reason TEXT,
not helper identity: an overbroad packed-tail gate that rendered the
non-empty reason for the empty-member shapes would pass it. Deliberate —
the operator-visible message is the contract — but it should not be read
as a structural pin, and nobody should add an assertion trying to make it
one.

Validation: go build ./... = 0, go vet ./... = 0, and a full unsandboxed
go test ./... under a FRESH GOCACHE = exit 0, 59 packages ok, zero
failures. No file crossed a refactor-audit tier
(compiler_validate_strict_zones.go 735, the test file 589 and excluded);
pkg/refactoraudit passes, so no heatmap regeneration is owed.

Advances #6735.
Resolve the sole conflict, _Log.md, by union: every entry from both
sides is retained and none is rewritten. The file is no longer
append-ordered, so line counts and prefix checks say nothing useful
about the result; the resolution was verified structurally instead, by
confirming that each pre-merge side diffs into the merged file with
add-hunks only and no changed-or-deleted hunk on either side.

Every other path merged without conflict. Because a clean textual
auto-merge can still break compilation when a signature moves on one
side, that was confirmed by building rather than by inspection: go build
./... clean on the merged tree.

Advances #6735.
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