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

StatusEmitted byBlocks promotion (mode)Blocks deployment (ReleaseTruthGuard)
passedallnono
deniedpolicy-engine, reachability (blocking), approval-policyyes (deny short-circuit)yes
waiting / failedsbom-readinessyes (waiting)yes
advisoryreachability or attestation (advisory failure)noyes, until a signed time-limited gate exception is recorded
skippedpolicy-engine (no pack bound), reachability (no/malformed witness, advisory)noyes, until the missing gate evidence is supplied or a signed time-limited gate exception is recorded
exceptionsigned 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:

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: