Evidence-Based Release Gates — As-Built Contract
Status: As-built (reconciled 2026-07-03, Sprint 20260703_002 DTC-10; decision basis requirement 2026-10-08, Sprint 20261007_004 CCP-055) Related: docs/modules/release-orchestrator/gates/reachability-gate.md, docs/modules/release-orchestrator/workflow/promotion.md, docs/modules/evidence/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 (the only wired path; the UsePluginRegistry
flag and CompositeGateDecisionEvaluator fall-through were deleted pre-release, P8-11)
|
|--> 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. The row’s details.reasonCode is policy_pack_absent, or policy_verdict_unavailable when a pack is bound but Policy.Engine could not be read and FailClosed=false. Neither case can produce an approve outcome (see “4. Decision basis” below).
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.
4. Decision basis (CCP-055)
Before the outcome is fixed, PluginBackedGateDecisionEvaluator classifies the aggregated rows with ReleaseTruthGuard.AssessApprovalBasis. An auto-approve or require-manual mode becomes outcome approve only when the rows carry the evaluated Policy pack identity and version, a findings projection on that Policy verdict, and a verified attestation for every candidate digest when the attestation gate is blocking for the target environment. A named approval-policy rule (one that carries a policy id) can stand in for an absent Policy verdict; the default mode cannot.
- An absent input records outcome
blocked(modedeny); the processor routes it like a deny. - An input whose owner read failed (
policy_verdict_unavailable,findings_projection_unavailable,attestation_unavailable) records outcomeunknown(modewaiting); the approval stays pending and not passing. - In both cases the decision
policyIdisdecision-basis, approval-policy rows that saidpassedcarry the refused status, and adecision-basisrow listsmissingInputsby reason code.
In a blocking attestation environment, BlockOnUnavailable=true turns an unreadable attestation state into a deny. With the default false the attestation row does not fail, but the basis records attestation_unavailable and the outcome is unknown, not approve. The full contract and reason-code table are in the module architecture (“Decision basis requirement”).
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) |
blocked | decision-basis, approval-policy (decision basis incomplete) | yes (routed like a deny) | yes |
unknown | decision-basis, approval-policy (owner read unavailable) | yes (approval stays pending, not passing) | yes |
At deploy time ReleaseTruthGuard.ValidateDeploymentRequest also refuses a latest gate decision whose recorded outcome is approve while its recorded basis lacks an input (gate_decision_basis_absent). That check runs after the approval-status, quorum, gate-row and evidence checks, so it decides only when every gate row is satisfied. Signed gate exceptions on the decision’s rows do not make such a decision dispatchable.
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: retired SecurityGate library (DTC-10)
Sprint 20260731_003 removed the unregistered Promotion/Gate/Security/SecurityGate library, its helper types, and tests. It had never been registered in the WebApi and included an unsupported fail_open_with_alert setting that could be mistaken for shipping behavior.
The live path above (Policy Engine verdict + reachability + SBOM readiness and attestation, composed through the plugin registry and re-checked by ReleaseTruthGuard) is authoritative. Any missing evidence-score, Rekor, or signer-threshold capability must be implemented and tested on that path; the retired gate must not be restored as a parallel decision engine.
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, plusblocked(decision basis incomplete, routed like deny) andunknown(owner read unavailable, routed like waiting).
