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); SPRINT_20260722_017 (SCN-ID-2: v1/v2 consumers-first reader rollout; SCN-ID-5: source-availability gate).

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, surfaced through ReachabilityGatePlugin on the plugin-registry path (PluginBackedGateDecisionEvaluator). 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 -> POST /v1/reachgraphs:
   |   retired 2026-09-14, SPRINT_20260914_001 VRP-5; no ReachGraph hop remains)
   |
   |  Sprint 019 RCH-019-003: HttpReleaseEvidencePublisher
   |  uploads canonical DependencyReachabilityReport bytes
   v
[POST /api/v1/release-orchestrator/evidence]
   - type=stellaops.reachability-report@v2 (hub-native producer)
     or stellaops.reachability-report@v1 (legacy producer/archive)
   - 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
PluginBackedGateDecisionEvaluator.EvaluateAsync (Sprint 023 GPM; the sole gate path)
   |
   |--> ApprovalPolicyGatePlugin -> PolicyDrivenGateDecisionEvaluator (approval-policy + policy-engine)
   |
   |--> ReachabilityGatePlugin -> ReachabilityGateEvaluator (Sprint 019 RCH-019-006)
           |
           |  Reads both release_evidence types for this tenant+release
           |  and selects the newest row across v2 and v1
           |
           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@v2 or @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 binding — release_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 list — BlockingEnvironments contains the target environment.
  3. Global — DenyOnFailure=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",
    "witnessType":       "stellaops.reachability-report@v2",
    "pathCount":         4,
    "reachableCount":    2,
    "totalReachable":    2,
    "advisorySourceStatus": "unavailable",  // on source-status failure
    "advisorySourceReason": "hub-product-issues-partial-outage", // when supplied
    "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

A structurally valid v2 witness is eligible to pass only when its exact summary.advisorySourceStatus is available. unavailable, unknown, not-applicable, or any future non-available value follows the ordinary environment-aware failed-check posture: denied when blocking, otherwise advisory. The exact source status and optional reason are copied to gate-row details. This applies even when a partial hub response contains findings; a partial product outage never becomes a clean or passing release witness.

Evidence-version selection and failure posture (SCN-ID-2/5)

The gate queries @v2 and @v1 independently, combines the returned rows, and selects the greatest createdAt. An exactly-timed tie selects @v2; rows of the same type/time are ordered by evidence id so replay is deterministic. Only that selected row is decoded. A malformed, unsupported, or type/version-disagreeing newest row produces the normal malformed-witness gate result and never falls back to an older clean row.

The v1 decoder remains deliberately frozen and accepts only the historical shape with no in-payload schemaVersion. The v2 decoder requires evidence type stellaops.reachability-report@v2, integer literal schemaVersion: 2, the declared report schema/advisory source, exact hub identity digest, valid observation hashes, required exact summary.advisorySourceStatus, and no legacy canonicalId. The reader preserves the optional exact summary.advisorySourceReason. The evaluator accepts only status available; any other status fails without falling back to older evidence and is surfaced as details.advisorySourceStatus / details.advisorySourceReason. A decoder failure’s stable code is surfaced as details.readFailure; the chosen row’s type is surfaced as details.witnessType. No stored v1 bytes are rewritten or promoted to hub authority.

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:

The legacy CompositeGateDecisionEvaluator fall-through and its ReleaseOrchestrator:GateEvaluator:UsePluginRegistry rollback flag were deleted pre-release (P8-11, AGENTS.md §2.11). PluginPathGateResultsParityTests pins byte-identical GateDecisionDto output between the plugin path and the retired composite chain. The domain-service evaluator classes (PolicyDrivenGateDecisionEvaluator, ReachabilityGateEvaluator) remain as the engines the plugin wrappers delegate to.

Relation to the retired SecurityGate library (decision record)

Sprint 20260731_003 deleted the separate, never-registered Promotion/Gate/Security/SecurityGate implementation and its unsupported fail-open setting. The live path (this gate + the Policy Engine verdict + SBOM readiness and attestation) is the only release-security gate chain. See workflow/evidence-based-release-gates.md for the as-built gate model.

See also