Skip to content

screen: expose unresolved profile references in status and metrics (advances #5806) - #6839

Open
psaab wants to merge 8 commits into
masterfrom
fix/5806-screen-unresolved-visibility
Open

screen: expose unresolved profile references in status and metrics (advances #5806)#6839
psaab wants to merge 8 commits into
masterfrom
fix/5806-screen-unresolved-visibility

Conversation

@psaab

@psaab psaab commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Summary

Drives the posture-independent half of #5806 — acceptance criterion 4,
"Runtime status and Prometheus expose the unresolved reference and enforcement
disposition; one warning is not the sole signal."

It deliberately does not pick the fail-closed-vs-pass posture. That is a real
availability-versus-security decision and it stays open under #5806.

STEP-0 against current origin/master (ad95911)

Criterion 4 is genuinely unmet. The missing-profile set already crosses the
wire as ConfigSnapshot.ScreenMissingProfiles and drives a rate-limited runtime
WARN — but git grep -ln "screen_missing_profile_zones\|ScreenMissingProfileZones"
returns 9 files, all of them either pkg/dataplane/userspace/protocol.go (the
wire field) or userspace-dp/** (the consumer + tests). Nothing in
pkg/api, pkg/grpcapi, or pkg/cli. So the WARN is the sole signal, which is
exactly what the criterion rules out.

This refines the earlier scoping note on the issue, which said "the observability
half already shipped" — true of the WARN, not of status or metrics.

The posture is orphaned, not an oversight. userspace-dp/src/screen/mod.rs:48-52
defers it to "the /research half of #3082"; #3082, #3908 and #3066 are all
CLOSED
, so #5806 is its only surviving owner. Untouched here.

What this adds

One SSOT. ScreenMissingProfileRefs is exported as a thin wrapper over the
existing snapshot builder, so the metric and both status renderers report the
exact set published to the helper rather than a re-derived predicate that
could drift. Config-derived is the right truth source here, not a
desired-vs-applied gap: the defect is "the active config claims a screen and
nothing enforces it", so the config is the authoritative statement of the
unresolved reference.

Prometheus. xpf_screen_unresolved_profile_zones{zone,profile,disposition},
emitted before the dataplane gate — mirroring its config-derived sibling
collectHostInboundAddresslessZones, whose doc comment states the same
"same builder the daemon logs from" discipline. A config-only / degraded boot is
exactly when an unenforced security control must stay visible. Present only
while a reference is unresolved, so max_over_time(...) alerts on any zone ever
left unscreened.

Status, both renderers. An Unresolved screen profile references: block in
local-CLI and gRPC show security screen.

Placement is the load-bearing detail. Both renderers early-return
No screen profiles configured when cfg.Security.Screen is empty — and that
is exactly the tolerant-load shape that strands a reference: definitions
gone, zone still claiming one, dataplane enforcing nothing. The operator was
being told, in as many words, that nothing was configured. The block is
emitted before that return.

On disposition. The label and the status sentence report what the dataplane
does today (not-enforced-pass — the verdict stays ScreenVerdict::Pass,
traffic is forwarded unscreened). A factual statement of current behaviour, not a
decision about the future posture. If you would rather that wording wait until
the posture is settled, it is one constant and one sentence to drop.

Proof — two mutations, both RED on an ASSERTION

go build + go vet returned 0 errors under each mutation first.

# mutation RED
M1 collector moved below the dp == nil || !dp.IsLoaded() gate TestScreenUnresolvedProfileZonesEmittedOnTolerantLoadseries = map[]
M2 status block moved inside the empty-Screen else branch TestShowScreenReportsUnresolvedReferenceWithNoProfilesDefined

Each restored and touched; both returned to green.

Every guard is paired with a negative control — a resolved reference must
emit no series and render no block — so none of them can pass by firing
unconditionally. Plus a nil-store no-panic case for the pre-dataplane-gate path.

The fixture reaches the defect through the real path

Strict commit rejects the dangling reference (verified firsthand:
commit check failed: security zone "trust" references undefined screen profile "no-such-profile"), so the state cannot simply be committed. The test therefore
commits a valid config, externally modifies the persisted active.json so
the profile definition no longer parses (the ids-option path token occurs
exactly once and is renamed) while the zone's reference survives, then
re-loads through store.Load() — reachability path 1 from the issue, and the
same shape an HA sync from a schema-skewed peer produces.

The fixture asserts its own assumption (ids-option count == 1) so it fails
loudly rather than silently testing nothing if the persisted shape ever changes.

Gates

Docs

docs/feature-coverage.md's Screen/IDS row now documents the three surfaces, the
shared SSOT, and — explicitly — that disposition describes current behaviour
rather than resolving the posture.

Not in scope

Acceptance criteria 1, 2, 3 and 5 (fence vs reject-snapshot vs quarantine,
cross-node determinism, transactional ordering with snapshot generation,
automatic recovery) all depend on the posture decision and are left to it.
Hence Advances #5806, not Closes.

Advances #5806.

🤖 Generated with Claude Code

https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi

Paul Saab and others added 3 commits August 5, 2026 09:31
A zone can reference a screen ids-option profile that is not defined.
Strict commit rejects that outright, but tolerant startup/recovery of an
older or externally modified active.json, HA config-sync from a
schema-skewed peer, and rolling-upgrade intervals all downgrade it to a
warning. The dataplane then enforces NONE of that zone's screen checks —
no LAND, fragment, source-route, SYN/ICMP/UDP flood, scan/sweep or
session-limit — while the active configuration still says a screen is
attached.

The missing-profile set already crosses the wire as
ConfigSnapshot.ScreenMissingProfiles and drives a rate-limited runtime
WARN in the Rust screen runtime. But that WARN was the ONLY signal:
nothing in pkg/api, pkg/grpcapi or pkg/cli referenced the set at all, and
a warning can be missed entirely during failover noise. The issue states
the requirement directly — one warning is not the sole signal.

Three surfaces now report it, all derived from ONE computation.

ScreenMissingProfileRefs is exported as the shared SSOT: a thin wrapper
over the existing snapshot builder, so the metric and the status block
report the exact set that was published to the helper rather than a
re-derived predicate that could drift from it. Config-derived is the
correct truth source here rather than a desired-versus-applied gap: the
defect is precisely that the ACTIVE CONFIG claims a screen while nothing
enforces it, so the config is the authoritative statement of the
unresolved reference.

xpf_screen_unresolved_profile_zones{zone,profile,disposition} is a gauge
emitted BEFORE the dataplane gate, mirroring its config-derived sibling
collectHostInboundAddresslessZones — a config-only or degraded boot is
exactly when an unenforced security control must stay visible. The series
is present only while a reference is unresolved, so max_over_time alerts
on any zone that was ever left unscreened.

Both `show security screen` renderers, local CLI and gRPC, now print an
"Unresolved screen profile references:" block. Placement is the load-
bearing detail: both renderers early-return "No screen profiles
configured" when cfg.Security.Screen is empty, and that is EXACTLY the
tolerant-load shape that strands a reference — the profile definitions
are gone, the zone still claims one, and the operator was being told in
as many words that nothing was configured. The block is therefore emitted
before that return.

The disposition label and the status sentence report what the dataplane
does with such a zone TODAY: not-enforced-pass, the verdict stays
ScreenVerdict::Pass and traffic is forwarded unscreened. That is a
statement of current behaviour, deliberately NOT a resolution of the
fail-closed-versus-pass posture. That posture is a genuine availability-
versus-security decision — a runtime fail-closed posture is itself an
availability brick under the #1960 no-brick rationale, and these tolerant
paths are the only ones that reach the state — and it remains open. It
was deferred to "the /research half of #3082", but #3082, #3908 and #3066
are all closed, so #5806 is its only surviving owner.

Validation: two mutations, each preceded by a 0-error go build + go vet so
every RED is an assertion. Moving the collector below the
`dp == nil || !dp.IsLoaded()` gate empties the series on a config-only
boot. Moving the status block inside the empty-Screen else branch
restores the bare "No screen profiles configured". Each was restored and
touched, and both returned to green. Every guard is paired with a
negative control asserting a RESOLVED reference emits and renders
nothing, so none can pass by firing unconditionally.

The pkg/api fixture reaches the defect through the real tolerant path
rather than simulating it. Strict commit rejects the dangling reference
(verified firsthand), so the test commits a valid config, externally
modifies the persisted active.json so the profile definition no longer
parses while the zone's reference survives, and re-loads it through
store.Load() — reachability path 1 from the issue. The fixture asserts
its own assumption about the persisted shape, so it fails loudly rather
than silently testing nothing if that shape ever changes.

go test ./... exits 0. gofmt is clean on every touched file; the five
pre-existing gofmt violations under pkg/dataplane/userspace/*_test.go are
untouched by this branch.

advances #5806

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

Fold of three review corrections on the #5806 visibility work.

Wording. "Traffic is forwarded UNSCREENED" overstated the behaviour. It
reads as a permit, and an operator could take it to mean the firewall is
passing traffic it would otherwise deny. What is actually true is
narrower: the screen checks are skipped, and nothing else about the
packet's treatment changes — zone security policy still evaluates it
normally. The disposition now reads "the profile reference does not
resolve, so no screen checks are applied to this zone; policy evaluation
is unaffected", which is both accurate and posture-neutral.

Derive-or-anchor. The skip decision is NOT derivable from the Go
emitter: it lives in the Rust runtime, where screen/mod.rs returns
ScreenVerdict::Pass on the None branch, and every Go mention of it is a
comment asserting it. That is precisely the shape that goes stale
silently — true when written, believed by a later reader after it stopped
being true. Since the string must be a constant, it now carries a literal
#5806 anchor, and the comment explains that the anchor is load-bearing
rather than decorative: when the enforcement posture is settled, a grep
for the issue number has to land on every place asserting today's
behaviour. Two tests assert the anchor survives into the metric HELP text
and into the rendered status block.

Label to HELP. The disposition was a metric label. It is a global
statement about the current implementation, identical for every series,
so it carried no information as a label — and a prose label value would
have handed us unbounded cardinality the day someone made it vary. The
label set is now exactly {zone, profile}, the two things that actually
differ per series, and the disposition rides in the descriptor's HELP
text plus one trailing status line. Both read the same exported
ScreenUnresolvedDisposition constant, so the metric and the CLI/gRPC
block cannot drift into describing the behaviour differently.

The ScreenMissingProfileRefs doc comment now states the SSOT reuse as the
contract rather than an implementation convenience: going through it is
what makes it impossible for a surface to report a different set than the
dataplane was actually told about. It also records why config-derived is
correct here and was wrong in #6828 — there an authoritative zero was
published from config while a fence was actively dropping; here the
defect IS a property of the configuration.

Validation: every mutation was re-gated after the fold, not just the new
ones, because a fold can introduce a regression. Each was preceded by a
0-error build so the RED is an assertion. Moving the collector below the
dataplane gate still empties the config-only-boot series. Moving the
status block inside the empty-Screen else branch still restores the bare
"No screen profiles configured". Restoring the old overstated wording
(which also drops the anchor) reddens three guards across all three
packages: the HELP anchor, the "policy evaluation is unaffected" check,
and the rendered-text anchor. Reintroducing the disposition label reddens
the label-set guard. Each file was restored and touched, and every case
returned to green.

One hazard is recorded in _Log.md: restoring a mutated file with
`git checkout --` reverts it to HEAD, which silently discarded this
fold's own uncommitted changes to metrics_counters.go. It was caught by
grepping the restored file instead of trusting the restore. Restore from
the pre-mutation snapshot, not from HEAD, when the file already carries
uncommitted work.

go test ./... exits 0; build, vet and gofmt clean.

advances #5806

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
Five gate findings, all of which were that a claim existed without a
guard behind it.

Policy evaluation is unaffected — now bound, not downgraded. The status
text and metric HELP assert that an unresolved screen-profile reference
skips the screen checks and leaves policy evaluation alone. Nothing
proved the second half, and it is not provable from Go: the decision
lives in the Rust runtime. It is now bound where it is decidable. A new
test drives the real stage_screen_check and asserts that a zone with an
unresolved reference yields StageOutcome::Continue — the packet is
neither dropped nor is its descriptor consumed — which is precisely what
"the poll loop goes on to evaluate zone policy" means at that seam.
Making the None branch Drop instead of Pass turns it red.

That test also pins a real gap it uncovered, which is not a property of
this change. stage_screen_check short-circuits on !screen.has_profiles(),
and has_profiles() is !self.zones.is_empty() — the RESOLVED map only. So
when the security screen stanza is absent ENTIRELY, which is exactly the
tolerant-load shape that strands a reference, the stage returns before
maybe_warn_missing_profile can run and the rate-limited runtime WARN
never fires at all. That WARN is the signal #3082 shipped and the one
#5806 treats as already existing. In that case the config-derived metric
and status block added here are the only signal there is. Asserted, so
the gap is pinned rather than described.

The local-CLI renderer had no test. Deleting its emit passed the entire
suite — only the gRPC renderer was covered. It now has an integration
test built on the same real tolerant-load fixture, capturing stdout, plus
a negative control.

The shared constant did not bind as an SSOT. The old tests checked that
the sentence appears in the metric HELP and in the rendered block, which
permits exactly what the constant exists to prevent: replacing both uses
with identical duplicated literals passes every one of them while leaving
the two surfaces free to drift. The new guard asserts common source
instead of equal values — the sentence must exist as a literal exactly
once, its const definition, with consumers reaching it by identifier.
Duplicating the literal passes every old test and fails only this one.

The snapshot publication path had no guard, even though the whole SSOT
argument rests on the snapshot actually carrying the set. Nulling
ScreenMissingProfiles in buildSnapshot previously passed everything.

The pkg/api test file now carries a header naming which cases bind the
fix and which are negative controls, so the case count is not
over-trusted.

Validation: four new mutations, each preceded by a 0-error build. The
first attempt at the local-CLI mutation broke the build on an unused
import, which would have been a false red; it was redone so the code
still compiles and the red comes from an assertion. The duplication
mutation is the four-cell proof — it escapes every old guard and is
caught only by the new one. go test ./... exits 0; cargo test --bins
--test-threads=1 reports 4235 passed, 0 failed.

Formatting note recorded in _Log.md: rustfmt on the single touched .rs
file still reformatted about 97 pre-existing lines across 46 hunks. The
additions were re-applied onto the pristine file instead, giving 171
insertions and 0 deletions in 2 hunks. "Format only the files you
touched" is not sufficient on this tree.

advances #5806

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
Paul Saab and others added 5 commits August 5, 2026 12:19
Six review findings, two of them major.

The publication guard could not run. buildSnapshot enumerates ip-rules
through real netlink, which fails "operation not permitted" in a
restricted sandbox before the screen assertion is ever reached, so the
guard added for the snapshot path silently never executed. Verified the
mechanism directly rather than on report: stubbing ruleListFn to return
an error reproduces "route snapshot: list ip-rules for family 2:
operation not permitted". The in-tree stubRuleListHermetic is now wired.

The same guard was also weaker than its claim. It compared only the Go
struct, so any break after the struct is built — a wire-tag rename, Rust
field drift — passed it, even though the whole SSOT argument rests on the
dataplane being told the same set. It now marshals the snapshot and
checks the screen_missing_profile_zones key and its zone/profile members,
which are the names the Rust decoder actually reads.

The policy test accepted a no-op on the thing it tested. Two shapes. It
accepted any Continue(_), which includes ScreenCheckOutcome::
SynCookieChallenge — an outcome that continues but answers the packet
with a challenge rather than carrying it on to policy, so it does not
satisfy the claim; the assertion is now the exact
Continue(ScreenCheckOutcome::Pass). And the missing-only subtest expected
warns == 0, which is also the failure default if the missing-profile
threading were deleted or update_missing_profiles were a no-op, so it
passed whether the mechanism worked or did not exist. It now arms the
gate afterwards on the same state — adding a resolved profile for an
unrelated zone, leaving the lan reference untouched — and requires the
warning to appear. That pair separates "the gate suppresses a working
mechanism" from "there is no mechanism".

Three claims corrected. A helper insertion had orphaned
source_route_screen's doc comment onto missing_only_screen, leaving a
source-route description above a function that contains only a missing
reference. The comment describing StageOutcome::Continue as necessarily
unconsumed is contradicted by the SYN-cookie arm; it now states that as
the reason the exact variant is asserted. And the "ONLY signal" wording
overstated: other reporting exists — tolerant-load configuration warnings
and daemon logging — so the defensible claim is that the runtime
dataplane WARN specifically cannot fire. Narrowed in pkg/api/metrics.go,
the Rust test comment and docs/feature-coverage.md, and corrected on
#6860 and #5806 so the record does not keep the stronger version.

Test acceptance. The CLI tests assert structure rather than token
containment: the heading exists, exactly one row sits under it carrying
both the zone and the profile, and the disposition appears exactly once
after the rows. The CLI silence control no longer rests on absence alone,
since an aborted render also contains no heading — it requires the
healthy inventory to render. The source-identity guard counts
occurrences rather than files, because a duplicate in the same file is as
drift-prone as one in another, and adds a split-concatenation check,
since a literal broken across + chunks defeats a plain substring scan.

Validation: four new mutations plus a re-gate of the prior set, each
preceded by a 0-error build. Making update_missing_profiles a no-op — the
exact escape identified in review — is now red where it previously
passed. Renaming the wire tag while the Go struct stays correct reddens
the publication guard. Dropping the heading while keeping the rows
reddens the CLI structural guard. On re-gate, moving the collector below
the dataplane gate is still red; the old-wording mutation now reddens
four packages rather than three, because the CLI guard picked it up too;
the CLI emit and publication mutations are still red. Each was restored
and touched, green after every one.

One narrowing is argued rather than demonstrated, and is marked as such
in _Log.md: producing a SynCookieChallenge for a missing-profile zone
would require contriving production code that cannot reach it, so the
escape the Continue(Pass) assertion closes is derived from the enum, not
shown by mutation.

go test ./... exits 0; cargo test --bins --test-threads=1 reports 4235
passed, 0 failed.

advances #5806

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015oARShYtiJJ2H4UB4nXGqi
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, and cargo check --all-targets clean
for the Rust crate.

Advances #6839.
@psaab

psaab commented Aug 14, 2026

Copy link
Copy Markdown
Owner Author

First independent review, @ 954a523b6: MERGE-NEEDS-MINOR

No runtime finding. The wiring is real — 12 of 14 mutations RED with captured exit codes:
severing the CLI emit, the gRPC emit, the metrics collector call; moving the collector below
the dp == nil || !dp.IsLoaded() gate; making the collector over-broad; dropping the snapshot
publication; renaming the JSON wire tag (all rc=1); and flipping the Rust None branch from
Pass to Drop (rc=101). The SSOT test is a genuine source-text scan, not a tautology — which
was the first thing I asked to be checked, because that is the usual failure of an SSOT test.

The defect class: documentation misattribution, three times

A new function, call, or test was each inserted between a neighbour and its doc comment:

  • pkg/api/metrics_counters.go:176-208 — proved with a go doc artifact: the screen collector
    is now documented as emitting xpf_host_inbound_addressless_zones
  • pkg/api/metrics.go:1121-1138
  • userspace-dp/src/afxdp/poll_stages_tests.rs:432-443

And the sharp part: _Log.md shows the author already caught and fixed one instance of this
exact class in that same Rust file.
So three survived a round in which the class was already
known and being corrected. That makes it a property of the editing motion rather than of
attention — inserting above a declaration is the natural place to put new code, and the doc
comment above it silently changes owner.

Proving it with go doc output rather than by eye is the right instrument: it shows what a
reader actually gets.

Two wording problems

  • A gRPC comment at :777 ships the "forwarded unscreened" framing this PR itself removed
    and now tests against.
  • The SSOT contract sentence — "impossible to report a different set than the dataplane was
    actually told about"
    , in three places — is over-strong, and false in this PR's own
    config-only-boot fixture
    where dp is nil. A contract sentence contradicted by a fixture in
    the same PR is the cheapest kind of claim defect to catch and the easiest to leave.

Two measured guard holes, both declared or cosmetic

A split-concatenation copy in a file outside the guard's two-item consumer list evades the
source-identity test (rc=0); and the gRPC test's own RED-on-revert claim holds for two of
three
placements (rc=0 for the third). Neither is a runtime hole; both are the guard claiming
more than it does.

Refuted, and recorded as a non-defect

The zone.Name-vs-map-key label source could in principle emit a duplicate label pair and
500 the whole /metrics endpoint — reproduced mechanically — but it is unreachable:
active.json stores the AST and compileZones always sets Name to the map key. Reproducing
a hazard and then proving it unreachable is worth more than not looking, and it belongs on the
record so nobody re-raises it.

The review worktree is unmodified; all mutations ran in a scratch copy.

This PR had no prior verdict of any kind. Everything above is its first gate.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant