Evidence-Based Release Gates — As-Built Contract
Status: As-built (reconciled 2026-07-03, Sprint 20260703_002 DTC-10) Related: docs/modules/release-orchestrator/gates/reachability-gate.md, docs/modules/release-orchestrator/workflow/promotion.md, docs/modules/evidence-locker/architecture.md
Purpose
Define the deterministic, policy-driven promotion gate model where release decisions are made from verifiable evidence, not ad hoc logic. This document describes the gate path as it actually runs in the live service — verify claims against src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/.
The live gate path (as-built)
Every promotion request runs through one chain:
POST /api/v1/release-orchestrator/releases/{id}/promote
|
v
PromotionRequestProcessor.ProcessAsync (Services/PromotionRequestProcessor.cs)
|
v
IGateDecisionEvaluator
= PluginBackedGateDecisionEvaluator (default; ReleaseOrchestrator:GateEvaluator:UsePluginRegistry=true)
| CompositeGateDecisionEvaluator (legacy fall-through, same evaluators)
|
|--> SbomReadinessGateEvaluator type=sbom-readiness (BLOCKING: waiting/failed rows deny dispatch)
|--> PolicyDrivenGateDecisionEvaluator
| |-- Policy.Engine CVE sub-gate (IPolicyEngineGateClient -> POST /api/policy/packs/{id}/revisions/{v}/evaluate)
| | type=policy-engine — CVE-aware, CAN BLOCK (deny short-circuits, fail-closed by default)
| |-- approval-policy rules type=approval-policy (auto-approve / require-manual / deny)
|--> ReachabilityGateEvaluator type=reachability-gate (DTC-10: ON by default, advisory;
| blocking per environment)
v
GateDecisionDto persisted to release_orchestrator.gate_decisions
approval row mutated (approved / pending / blocked) with gateResults[]
|
v (deploy time)
ReleaseTruthGuard.ValidateDeploymentRequest — fail-closed re-check of the SAME
persisted gate rows + verified evidence before any deployment dispatches
1. Policy.Engine CVE sub-gate (real finding data)
The subject string release:{id}|env:{env}|tenant:{t}|approval:{id} carries the release id; Policy.Engine (PostgresFindingsLookup) resolves the release’s ACTUAL per-component-digest findings server-side: operator-curated release_orchestrator.release_components rows plus SBOM PURL fan-in from scanner.scan_evidence_projections (keyed on the pinned image digests), joined against vuln.advisories/vuln.advisory_affected with version-range filtering and reachability states from the scan projections. The pack’s DSL rules (finding.cve_id denylists, reachability-severity rules) run against that surface.
Since DTC-10 the response’s structured evidence (every matched (rule, CVE, package, severity, reachabilityState)) is read back by HttpPolicyEngineGateClient and persisted into the gate row’s details.cveMatches (plus totalFindingsCves, denylistRuleCount), and a deny reason names the matched CVEs. Deny short-circuits the chain — even an auto-approve approval-policy rule cannot override it. Failures calling Policy.Engine are fail-closed by default (ReleaseOrchestrator:Policy:FailClosed=true).
No policy pack bound = visibly not evaluated. Pack resolution order: DB binding (release_orchestrator.environment_policy_bindings) → deprecated config map → tenant default (ReleaseOrchestrator:Policy:DefaultPackId). When nothing resolves, the decision carries an explicit row (gateId=policy-engine-skipped, status=skipped, details.policyBound=false) whose message states the CVE gate was NOT evaluated and how to bind a pack. It is never a silent skip.
2. Reachability gate
See gates/reachability-gate.md. DTC-10 defaults: enabled, advisory; blocking per environment via environment_policy_bindings.reachability_blocking (DB) or ReleaseOrchestrator:ReachabilityGate:BlockingEnvironments (config), global DenyOnFailure still honoured.
3. SBOM-readiness gate
SbomReadinessGateEvaluator blocks promotion while any digest-pinned release component lacks a ready SBOM (states absent/pending/failed → waiting/failed rows). Operators can trigger POST /releases/{id}/components/{name}/scan to produce the missing evidence — the gate itself is never bypassed.
Gate row statuses and blocking semantics
| Status | Emitted by | Blocks promotion (mode) | Blocks deployment (ReleaseTruthGuard) |
|---|---|---|---|
passed | all | no | no |
denied | policy-engine, reachability (blocking), approval-policy | yes (deny short-circuit) | yes |
waiting / failed | sbom-readiness | yes (waiting) | yes |
advisory | reachability or attestation (advisory failure) | no | yes, until a signed time-limited gate exception is recorded |
skipped | policy-engine (no pack bound), reachability (no/malformed witness, advisory) | no | yes, until the missing gate evidence is supplied or a signed time-limited gate exception is recorded |
exception | signed gate override (POST /approvals/{id}/gate-exception) | n/a (post-decision) | no (DTC-10 fix — the guard now honours the override contract) |
Audit and replay (as-built)
For each gate decision the processor persists:
- the full
GateDecisionDto(outcome, mode, policyId, reason, per-gate rows with structured details) torelease_orchestrator.gate_decisions; - a
policy-decisionevidence packet (canonical JSON, sha256-hashed) torelease_orchestrator.release_evidence; - signed deployment-decision referrers per digest-pinned component (DSSE, via
IDeploymentDecisionEmitter) carrying verdict, SBOM readiness and reachable CVEs; - approve/reject/gate-exception operator decisions as DSSE envelopes attached to the approval (
OperatorDecisionEndpointGuard).
Before the basis and decision envelope are built, DecidedAt is converted to UTC and normalized to PostgreSQL timestamptz microsecond precision. This is a signature boundary: signing the finer .NET tick value and later replaying the database value would otherwise produce different canonical bytes. Protected environments must configure the evidence verifier with public trust anchors for both externally signed release evidence and the Release Orchestrator’s own gate-decision signing key; private keys never belong in that verifier setting. The durable DSSE envelope uses standard padded base64 for payload and sig; provider-produced unpadded base64url is normalized without changing the signed bytes before the envelope is persisted and submitted for evidence verification.
Decision record: the dark SecurityGate library (DTC-10)
src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Promotion/Gate/Security/SecurityGate.cs (~59 KB, with SecurityGateConfig and a DecisionEngine-era model of evidence scores, Rekor freshness, in-toto build links and k-of-n DSSE thresholds) is dark: it is constructed only by its own unit tests and has never been registered in the live WebApi DI container. Earlier revisions of this document described its checks as the implemented gate contract — that was aspirational.
Decision (Sprint 20260703_002 DTC-10): do NOT wire it. The live gate path above (Policy.Engine CVE sub-gate + reachability gate + SBOM-readiness, all composed through the plugin registry and re-checked by ReleaseTruthGuard) supersedes it. Maintaining two parallel gate brains is the failure mode this sprint exists to eliminate. The library is slated for removal in a future two-brains consolidation pass; any still-missing capability it modelled (evidence-score thresholds, Rekor freshness windows, k-of-n signer counting) must be added to the live path, not revived in the dark one.
Aspirational remainder (NOT implemented on the promotion path)
Kept for design context only — do not cite as behaviour:
- Gate policy as versioned CUE/JSON profiles with
evidence.min_score,signatures.k_of_n,rekor.*,retry.*,escalation.*,slo.*knobs. hold_async/escalatedecision outcomes with TTL re-evaluation and SLO targets (P50 ≤ 200 ms etc.). The live processor’s outcomes are auto-approve / require-manual (pending) / waiting / deny.
