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
- Gate
typeingateResults[]:reachability-gate. - Evidence types consumed:
stellaops.reachability-report@v2and the permanent archivalstellaops.reachability-report@v1reader.
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).
| 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@v2 or @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",
"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
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.
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:
- 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 soleIGateDecisionEvaluator.
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
- 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
