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
- Gate
typeingateResults[]:reachability-gate. - Evidence type consumed:
stellaops.reachability-report@v1.
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).
| Key | Type | Default | Meaning |
|---|---|---|---|
Enabled | bool | true(DTC-10) | Master switch. When false the evaluator emits no row at all (pre-DTC-10 gateResults[] shape). |
RequireReachableWitness | bool | true | When true a release with no stellaops.reachability-report@v1 evidence fails the gate (advisory skipped row by default; denied in blocking environments — fail-closed). |
MaxAllowedPaths | int | int.MaxValue | Cap on the number of reachable findings tolerated. Above this the gate fails. |
MinConfidence | double | 0.0 | Per-finding confidence floor. Findings below this confidence are treated as unreachable. |
RequireSubjectPurlPrefixes | string[] | [] | Optional PURL prefixes the reachable findings must belong to (e.g. pkg:nuget/Org.). Empty means any subject is accepted. |
DenyOnFailure | bool | false | GLOBAL blocking switch: a failing gate sets Mode=deny and short-circuits the composite in EVERY environment. Prefer the per-environment switches below. |
BlockingEnvironments | string[] | [] | 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):
- DB binding —
release_orchestrator.environment_policy_bindings.reachability_blocking(migration009_reachability_gate_blocking.sql). Operator-managed viaPUT /api/v1/release-orchestrator/environments/{environment}/policy-bindingswith body fieldreachabilityBlocking:true= blocking,false= explicitly advisory (overrides config in either direction),null/omitted = no opinion. Note: binding rows require apackId, 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. - Config list —
BlockingEnvironmentscontains the target environment. - Global —
DenyOnFailure=true. - 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):
deniedrows BLOCK deployment (gate_decision_not_passing) — a blocking reachability verdict blocks promotion at gate time AND deployment at dispatch time through the same persisted gate state.advisoryand reachabilityskippedrows do not block promotion evaluation, but approval safety treats them as incomplete gate truth. Deployment therefore remains blocked until a later evaluation passes or an authorized operator records a signed, time-limited gate exception. The exception endpoint accepts these transition-blocking statuses even though they are advisory at promotion time.exceptionrows (signed gate override viaPOST /api/v1/approvals/{id}/gate-exception) are non-blocking — DTC-10 also fixed the guard to honour the documented override contract at deploy time.
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
passed— witness present, all checks satisfied.Modeleft unchanged.advisory— witness present, a check FAILED, environment is advisory (DTC-10). Message is prefixedADVISORY (non-blocking):and carries the same CVE evidence a deny would; the composite promotion mode is unchanged. Approval safety still requires completed truth or a signed, time-limited exception before deployment.denied— a check failed (or witness missing/malformed withRequireReachableWitness) AND the environment is blocking. SetsMode=deny, short-circuits the composite, and blocks deployment viaReleaseTruthGuard.skipped— no witness present OR witness malformed, in an advisory environment. The composite promotion mode is unchanged, but approval/deployment remains blocked until evidence is supplied or a signed exception is recorded.
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:
- Plugin host self-check at WebApi startup logs:
Plugin host self-check: IGateProviderRegistry resolved; 2 gate provider(s) discovered. IGateProviderRegistryenumerates two built-in plugins:io.stellaops.release-orchestrator.gates.approval-policy@1.0.0io.stellaops.release-orchestrator.gates.reachability@1.0.0
PluginBackedGateDecisionEvaluatoris the defaultIGateDecisionEvaluator.
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
ReachabilityGateEvaluator) and theGateDecisionDtoshape is identical to the plugin path (PluginPathGateResultsParityTestspins byte-identical output for both paths). The domain-service classes are marked[Obsolete(error: false)]and slated for removal in the release AFTER the plugin path has run hot for one milestone.
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
- Cross-module integration sequence: docs/modules/reach-graph/guides/policy-gate-integration.md
- Scanner emit point: docs/modules/scanner/callpaths-build-stage-sbom.md
- Sprint 023 dossier: docs/implplan/SPRINT_20260527_023_ReleaseOrchestrator_gate_plugin_migration.md
