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.

  • gateExclude keeps today's winner-path behavior: an unhealthy position is failed ahead of Phase 1, and an all-unhealthy eligible set answers remote.ErrAllUpstreamsUnhealthy.
  • gateMark leaves 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)

Merge request reports

Loading
Loading