feat(virtual): add a health-gate mode that marks instead of excluding (S13 collection mode plan: 3/6)
Step 3 of the shared internal/virtual collection mode. loadAndFilter gains
a health-gate mode, and each excluded position's Reason is recorded on the
position rather than dropped.
gateExcludekeeps today's winner-path behavior: an unhealthy position is failed ahead of Phase 1, and an all-unhealthy eligible set answersremote.ErrAllUpstreamsUnhealthy.gateMarkleaves the position for Phase 1, so its cached row stays readable, and answers no all-unhealthy aggregate.
Both modes record the health mark and the eligibility pass's Reason on
positionDetail, so a per-position reader never has to infer either from the
list-wide st.dataError.
Resolve passes gateExclude and nothing passes gateMark, so no exported
behavior changes. resolve_test.go is untouched, which is the falsifiable
half of the step's acceptance.
What gateMark does not do
It bars nothing past Phase 1. A marked position stays undecided,
decisionForLookup leaves a remote Miss or stale hit undecided, and probeSet
collects every undecided position, so a caller that goes on to runPhase2
probes the remote the mark was meant to spare. This was confirmed by driving
loadAndFilter(gateMark) into runPhase1 and runPhase2 against a
remotetest.FakeProber: the probe lands.
Nothing reaches it today, Resolve being the only caller. The collection entry
point owns that bar, and Step 4 adds it. gateMark's own doc says so, because
neither the plan nor this description travels to resolve.go.
Please read that section rather than assuming the mark implies no traffic. It is the one place where a safety property that used to be enforced by the type became a caller contract.
Reviewing
Every new branch was falsified rather than reasoned about. Dropping or
inverting the gate on the per-position fail, dropping or inverting it on the
aggregate arm, dropping either of the two new recordings, and making the health
gate emit an audit event each redden internal/virtual.
Both gate reads are switch statements, so exhaustive
(default-signifies-exhaustive: false) requires an arm per mode and a third
mode cannot be absorbed silently into the fail-open direction. The aggregate arm
moved into allEligibleUnhealthy to hold loadAndFilter under gocognit's 16.
The comment sweep covers the seven resolve.go blocks that stated the exclusion
or its aggregate arm unconditionally, decision.go's failed cause list, and
four blocks whose quantifier the code does not honor: the
remote.UpstreamExclusionAuditSink doc and its method doc, loadAndFilter's
own doc, and UpstreamExclusionEvent.Phase. orphan_upstream_shapes.md gains
the pre-Phase-1 exclusion set that serveOutage's pointer promises, and its
index of short blocks goes from one entry to two.
Merge order
No Depends on edge either way. S32's Steps 9, 10a and 11 also modify
internal/virtual/resolve.go, and 10a edits loadAndFilter itself, but this
step owns no line any of them owns. Order is either, and whichever lands second
rebases. If 10a lands second it re-completes decision.go's cause list, which
is a semantic overlap with no textual conflict to force the edit.
Checked at the time of writing: no open MR touches any of this branch's five files.
Size
688 reviewable LOC, past the 500 docs/dev/development-model.md asks to
justify. Measured against the merge base 3774b4f9b: the test file is 478/0,
and the four non-test files are 118/92 together, so about 70% of the diff is
the test suite. Splitting does not help, because the branch is one test-first
pair and separating the tests from the implementation they constrain is what
the pair exists to prevent.
e2e scenarios
No scenario in docs/testing/ is added or affected. gateMark is unreachable
from any route until Step 4 wires the collection entry point, and no catalog
scenario covers an unhealthy upstream today.
Closes #1246 (closed)