Reachability Promotion Gate

Sprints: SPRINT_20260526_019_Scanner_reachability_live_forcing_function (RCH-019-006); SPRINT_20260703_002_ReleaseOrchestrator_deployment_truth_and_coherence (DTC-10: default-on advisory + per-environment blocking).

The reachability gate is a promotion-time gate that checks whether the Scanner has produced a credible reachability witness for the release under promotion and applies operator-configured thresholds (max reachable findings, minimum confidence, PURL prefix scoping).

It is a IPromotionGateEvaluator co-located with the existing PolicyDrivenGateDecisionEvaluator and composed via CompositeGateDecisionEvaluator. Since DTC-10 the gate is ENABLED by default in ADVISORY (non-blocking) mode: every promotion carries a visible reachability-gate row, but a failing check only blocks in environments the operator has flagged as blocking (see “Enforcement” below). Set ReleaseOrchestrator:ReachabilityGate:Enabled=false to suppress the row entirely (pre-DTC-10 gateResults[] shape).

Gate identity

Evidence flow

Scanner.Worker stage 'emit-reports'
   |
   |  Reachability union graph already in CAS (existing behaviour)
   |
   |  Sprint 019 RCH-019-002: HttpReachGraphPublisher
   v
[POST /v1/reachgraphs] -- ReachGraph WebService stores subgraph, returns digest
   |
   |  Sprint 019 RCH-019-003: HttpReleaseEvidencePublisher
   |  uploads canonical DependencyReachabilityReport bytes
   v
[POST /api/v1/release-orchestrator/evidence]
   - type=stellaops.reachability-report@v1
   - hash = sha256(uploaded bytes)         <-- bytes-uploaded == bytes-hashed
   - Description = "ReachGraph digest: <blake3:...>"
   |
   v
release_orchestrator.release_evidence row persisted (RLS on tenant)

... time passes, an operator requests a promotion ...

PromotionRequestProcessor.ProcessAsync
   |
   v
CompositeGateDecisionEvaluator.EvaluateAsync (Sprint 019 RCH-019-005)
   |
   |--> PolicyDrivenGateDecisionEvaluator (approval-policy + policy-engine)
   |
   |--> ReachabilityGateEvaluator (Sprint 019 RCH-019-006)
           |
           |  Reads latest release_evidence row of type
           |  stellaops.reachability-report@v1 for this tenant+release
           |
           v
        Produces 1 gateResults[] row of type=reachability-gate

Configuration

Section: ReleaseOrchestrator:ReachabilityGate (bound to ReachabilityGateOptions).

KeyTypeDefaultMeaning
Enabledbooltrue(DTC-10)Master switch. When false the evaluator emits no row at all (pre-DTC-10 gateResults[] shape).
RequireReachableWitnessbooltrueWhen true a release with no stellaops.reachability-report@v1 evidence fails the gate (advisory skipped row by default; denied in blocking environments — fail-closed).
MaxAllowedPathsintint.MaxValueCap on the number of reachable findings tolerated. Above this the gate fails.
MinConfidencedouble0.0Per-finding confidence floor. Findings below this confidence are treated as unreachable.
RequireSubjectPurlPrefixesstring[][]Optional PURL prefixes the reachable findings must belong to (e.g. pkg:nuget/Org.). Empty means any subject is accepted.
DenyOnFailureboolfalseGLOBAL blocking switch: a failing gate sets Mode=deny and short-circuits the composite in EVERY environment. Prefer the per-environment switches below.
BlockingEnvironmentsstring[][]DTC-10: per-environment blocking via config. Target environments listed here (case-insensitive) treat a failing check as a hard deny; all others stay advisory.

Enforcement (advisory vs blocking) — DTC-10

Blocking is resolved PER (tenant, target environment) at evaluation time, in this precedence order (ReachabilityGateEvaluator.ResolveEnforcementAsync):

  1. DB bindingrelease_orchestrator.environment_policy_bindings.reachability_blocking (migration 009_reachability_gate_blocking.sql). Operator-managed via PUT /api/v1/release-orchestrator/environments/{environment}/policy-bindings with body field reachabilityBlocking: true = blocking, false = explicitly advisory (overrides config in either direction), null/omitted = no opinion. Note: binding rows require a packId, so a pack-less tenant uses the config switch instead. A DB read failure degrades to the config posture (logged warning) — it never throws the promotion.
  2. Config listBlockingEnvironments contains the target environment.
  3. GlobalDenyOnFailure=true.
  4. Default — advisory.

The resolved posture is surfaced on every gate row as details.enforcement ("blocking"/"advisory") and details.enforcementSource ("environment-binding" / "config-blocking-environments" / "config-deny-on-failure" / "default-advisory").

Interplay with the deployment guard (ReleaseTruthGuard.ValidateDeploymentRequest):

Sample configuration

ReleaseOrchestrator:
  ReachabilityGate:
    Enabled: true                 # default since DTC-10
    RequireReachableWitness: true
    MaxAllowedPaths: 0            # Fail on any reachable vuln.
    MinConfidence: 0.6
    RequireSubjectPurlPrefixes: []
    DenyOnFailure: false          # keep global posture advisory...
    BlockingEnvironments:
      - production                # ...but hard-block promotion into production

Gate row shape

A single row of type=reachability-gate:

{
  "gateId":      "reachability-gate-evi-abcdef0123",
  "gateName":    "Reachability gate",
  "type":        "reachability-gate",
  "status":      "passed",       // or "advisory" / "denied" / "skipped"
  "message":     "reachability gate passed: 2 reachable findings (totalReachable=2, pathCount=4).",
  "details":     {
    "witnessId":         "evi-abcdef0123",
    "pathCount":         4,
    "reachableCount":    2,
    "totalReachable":    2,
    "enforcement":       "advisory",          // or "blocking" (DTC-10)
    "enforcementSource": "default-advisory",  // or environment-binding / config-* (DTC-10)
    "reachableCves":     [ { "cveId": "CVE-...", "purl": "pkg:...", "severity": "high", "confidence": 0.95, "pathLength": 2 } ],
    "graphDigest":       "blake3:..."  // present when the evidence Description carries it
  },
  "evaluatedAt": "2026-05-26T00:00:00.0000000+00:00"
}

Status semantics

Drill-in to the ReachGraph store

When the evidence row’s description carries ReachGraph digest: <blake3:...> (set by the Scanner Worker’s HttpReleaseEvidencePublisher), the gate row’s details.graphDigest carries it forward. UI consumers can drill into GET /v1/reachgraphs/<digest> for the full subgraph.

Sprint 023 plugin migration (canonical path)

Sprint 20260527.023 (GPM-003 / GPM-004 / GPM-006) migrated both built-in promotion gates (approval-policy and reachability-gate) to the plugin-registry path. The canonical implementation is now StellaOps.ReleaseOrchestrator.WebApi.GatePlugins.ReachabilityGatePlugin (GateType = reachability-gate), wrapping the legacy ReachabilityGateEvaluator behind a BuiltInGatePluginEnvelope.ContributionKey envelope so the plugin path produces a byte-identical GateResultDto[] row.

Operator visibility:

Fall-through escape hatch (operator-facing):

If a regression surfaces post-deployment, the legacy CompositeGateDecisionEvaluator path remains wired for one release behind the feature flag:

"ReleaseOrchestrator": {
  "GateEvaluator": {
    "UsePluginRegistry": false
  }
}

When set to false, gate evaluation runs through the chain of IPromotionGateEvaluator instances (legacy PolicyDrivenGateDecisionEvaluator

Relation to the dark SecurityGate library (decision record)

src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Promotion/Gate/Security/SecurityGate.cs is a separate vuln/KEV/VEX gate engine that is never registered in the live service (constructed only by its own tests). Decision (Sprint 20260703_002 DTC-10): it is NOT wired into the live gate path — the live path (this gate + the Policy.Engine CVE sub-gate + SBOM-readiness) supersedes it, and the dark library is slated for removal in a future two-brains consolidation pass. See workflow/evidence-based-release-gates.md for the as-built gate model.

See also