Policy Engine Architecture (v2)

Audience: Policy Engine service owners, platform engineers, and integrators wiring CI/CD release gates. Ownership: Policy Guild • Platform Guild
Services: StellaOps.Policy.Engine (Minimal API + worker host)
Data Stores: PostgreSQL (policy.* schemas for packs, runs, exceptions, receipts), Object storage (explain bundles), optional queue
Related docs: Policy overview, DSL, SPL v1, Lifecycle, Runtime, Governance, REST API, Policy CLI, Architecture overview, AOC reference, AI Code Guard policy Origin: Epic 2 (Policy Engine & Policy Editor) and Epic 4 (Policy Studio).

This dossier describes the internal structure of the Policy Engine service delivered in Epic 2. It focuses on module boundaries, deterministic evaluation, orchestration, and integration contracts with Concelier, Excititor, SBOM Service, Authority, Scheduler, and Observability stacks.

Tenant identity (envelope-bound, 2026-04-30). Policy Engine endpoints resolve tenant identity exclusively from the envelope-attached stellaops:tenant claim via IStellaOpsTenantAccessor. The legacy X-Tenant-Id header is stripped at ingress and is never honoured as a tenancy source. Literal "default" tenant fallbacks have been removed from AirGap/PostgresAirGapStateStores.cs, Audit/PolicyAuditBeforeStateProvider.cs, Audit/PolicyAuditResourceEnricher.cs, ConsoleExport/PostgresConsoleExportStores.cs, Endpoints/AirGapNotificationEndpoints.cs, Endpoints/DeterminizationConfigEndpoints.cs, Endpoints/SealedModeEndpoints.cs, Endpoints/StalenessEndpoints.cs, and the affected gateway endpoints; requests without a resolved tenant fail closed with HTTP 401. See SPRINT_20260430_007_MultiTenant_identity_sweep (TENANT-POLICY-01).

The service operates strictly downstream of the Aggregation-Only Contract (AOC). It consumes immutable advisory_raw and vex_raw documents emitted by Concelier and Excititor, derives findings inside Policy-owned collections, and never mutates ingestion stores. Refer to the architecture overview and AOC reference for system-wide guardrails and provenance obligations.


1 · Responsibilities & Constraints

Non-goals: policy authoring UI (handled by Console), ingestion or advisory normalisation (Concelier), VEX consensus (Excititor), runtime enforcement (Zastava).

1.1 · Localization runtime contract (Sprint 20260224_002)

1.2 · Simulation compatibility contract (Sprint 20260309_011)


2 · High-Level Architecture

graph TD
    subgraph Clients
        CLI[stella CLI]
        UI[Console Policy Editor]
        CI[CI Pipelines]
    end
    subgraph PolicyEngine["StellaOps.Policy.Engine"]
        API[Minimal API Host]
        Orchestrator[Run Orchestrator]
        WorkerPool[Evaluation Workers]
        Compiler[DSL Compiler Cache]
        Materializer[Effective Findings Writer]
    end
    subgraph RawStores["Raw Stores (AOC)"]
        AdvisoryRaw[(PostgreSQL
advisory_raw)] VexRaw[(PostgreSQL
vex_raw)] end subgraph Derived["Derived Stores"] PG[(PostgreSQL `policy.*`
packs / pack_versions / evaluation_runs / explanations / cvss_receipts / ...)] Blob[(Object Store / Evidence Locker)] Queue[(PostgreSQL policy job state
orchestrator_jobs / worker_results)] Cache[(Signed messaging cache backend
Router-selected / standalone Postgres)] end Concelier[(Concelier APIs)] Excititor[(Excititor APIs)] SBOM[(SBOM Service)] Authority[(Authority / DPoP Gateway)] CLI --> API UI --> API CI --> API API --> Compiler API --> Orchestrator Orchestrator --> Queue Queue --> WorkerPool Concelier --> AdvisoryRaw Excititor --> VexRaw WorkerPool --> AdvisoryRaw WorkerPool --> VexRaw WorkerPool --> SBOM WorkerPool --> Materializer Materializer --> PG WorkerPool --> Blob API --> PG API --> Cache API --> Blob API --> Authority Orchestrator --> PG WorkerPool --> Cache Authority --> API

Key notes:

2.0a · Messaging-backend plugin decouple (c1922c1ce5)

The Policy Engine and Policy Gateway hosts no longer compile-time link the PostgreSQL messaging transport. Previously both Program.cs files ProjectReferenced StellaOps.Messaging.Transport.Postgres and hand-registered PostgresConnectionFactory / PostgresTransportOptions / IDistributedCacheFactory → PostgresCacheFactory from the host project graph (Gateway also carried the deferred Router.Transport.Messaging reference). As of c1922c1ce5 both references are dropped and the messaging cache transport is selected at runtime from a signed mounted bundle via AddMessagingPlugins(...) (StellaOps.Messaging.DependencyInjection, RequireTransport = true). The transport plugin registers its connection, queue, and cache factories.

OSK-P1R makes ownership explicit for the merged Policy Engine host. When Router is enabled, Policy reuses Router’s one signed messaging-backend registration; the canonical Compose profile selects base / Valkey. Policy does not mount or register a second transport. When Router is disabled, the standalone host mounts one Policy-owned transport and defaults it to Postgres. Startup fails before provider build unless exactly one unkeyed IDistributedCacheFactory is present, so the effective cache backend can no longer depend on service-registration order. These stores contain bounded evaluation and reachability cache entries only; authoritative Policy state and signing projections remain PostgreSQL-owned.

Current canonical Compose is Router-enabled and uses the admitted signed base / Valkey bundle. A signed recommended / Postgres bundle remains an operator-supplied prerequisite only for Router-disabled standalone deployment; it is not present in the current local mounted profile (verified 2026-07-19).

2.0b · Fail-closed messaging-plugin loader (c1922c1ce5)

Loading a transport bundle from a mounted directory is a supply-chain boundary, so the shared MessagingPluginLoader.LoadFromDirectory(...) (src/Router/__Libraries/StellaOps.Messaging/Plugins/MessagingPluginLoader.cs) is now fail-closed (it was previously fail-open). Every mounted bundle must clear the shared SignedRuntimePluginAdmission chain before its assembly is handed to the loader:

A bundle that fails admission registers zero transports. Enforcement also requires a configured trust root: with EnforceSignatureVerification = true and no TrustRootPath, the loader rejects rather than falling open. Because this is the shared loader, the same hardening closes the Gateway.WebService exemplar’s hole as well. EnforceSignatureVerification defaults to true (MessagingPluginOptions); image-resident transports discovered from the Default ALC are intentionally not re-gated here (they are signed at build time and ride the host image’s trust boundary — see docs/modules/router/architecture.md). Coverage: MessagingPluginLoaderAdmissionTests (6 reject paths admit zero transports) plus a live accept / wrong-trust-root forcing-function on the real signed Postgres bundle (StagedMessagingTransportBundleAdmissionTests).

The admission primitive itself (SignedRuntimePluginAdmission) was promoted into StellaOps.Plugin/Security as a parameterized cross-module chokepoint (module + capability + contract version are caller inputs) so a single hardened implementation serves messaging, AdvisoryAI, the SM crypto pack loader, and the BinaryIndex/ExportCenter/Doctor mounted loaders without per-module forks.


2.1 · AOC inputs & immutability


3 · Module Breakdown

Path note. Paths below are relative to src/Policy/StellaOps.Policy.Engine/ unless they name a sibling project (StellaOps.PolicyDsl/ is a direct sibling under src/Policy/; StellaOps.Policy.Determinization/ lives under src/Policy/__Libraries/) or a shared library (the StellaOps.Policy gates library is also under src/Policy/__Libraries/). The merged engine host (Program.cs) wires all of these plus the former standalone gateway endpoints under Endpoints/Gateway/.

ModuleResponsibilityNotes
Configuration (Options/)Bind PolicyEngine settings (Authority, Workers, ResourceServer, Compilation, Activation, Telemetry, Entropy, RiskProfile, caches, exception lifecycle) plus Postgres:Policy, AirGap, Determinization; validate on start (PolicyEngineOptions.Validate, ValidateOnStart).Strict schema; production guards fail fast on missing/insecure secrets.
Authority / resource server (Program.cs, DependencyInjection/)Acquire client-credentials tokens, enforce policy:* / effective:write scopes via AddStellaOpsResourceServerAuthentication + scope handler.Only service identity uses effective:write. Accepts aud=stellaops plus configured audiences.
DSL Compiler (StellaOps.PolicyDsl/ + Compilation/)Tokenize/parse stella-dsl@1 source (DslTokenizer, PolicyParser), build/serialize IR (PolicyIr, PolicyIrSerializer, PolicyCompiler); engine-side complexity/metadata analysis + caching (PolicyCompilationService, PolicyComplexityAnalyzer, PolicyMetadataExtractor).DSL lives in the sibling StellaOps.PolicyDsl project, not an engine subdir. Reload recompiles persisted source text.
Selection / join (SelectionJoin/, ReachabilityFacts/)SBOM ↔ advisory ↔ VEX joiners; reachability-facts joining (ReachabilityFactsJoiningService); apply equivalence tables; support incremental cursors.Deterministic ordering (SBOM → advisory → VEX).
Evaluator (Evaluation/)Execute IR with first-match semantics, compute severity/trust/reachability weights, record rule hits, and emit a unified confidence score with factor breakdown (reachability/runtime/VEX/provenance/policy).Stateless; all inputs provided by selection layer.
Signals (Signals/)Normalizes reachability, trust, entropy, uncertainty, runtime hits into a single dictionary passed to Evaluator; supplies default unknown values when signals missing. Entropy penalties are derived from Scanner layer_summary.json/entropy.report.json (K=0.5, cap=0.3, block at image opaque ratio > 0.15 w/ unknown provenance) and exported via policy_entropy_penalty_value / policy_entropy_image_opaque_ratio; SPL scope entropy.* exposes penalty, image_opaque_ratio, blocked, warned, capped, top_file_opaque_ratio.Aligns with signals.* namespace in DSL.
Materialiser (Materialization/, EffectiveDecisionMap/)Upsert effective decision/findings state, append history, manage explain bundle exports.PostgreSQL transactions per SBOM chunk.
Orchestrator (Orchestration/, IncrementalOrchestrator/, Workers/)Change-stream ingestion, fairness, retry/backoff, persisted orchestrator-job queue (PersistedOrchestratorJobStore), worker host (PolicyOrchestratorJobWorkerHost, PolicyWorkerService).Jobs persist to policy.orchestrator_jobs / policy.worker_results; works with Scheduler Models DTOs.
API (Endpoints/, merged gateway under Endpoints/Gateway/)Minimal API endpoints, DTO validation, problem responses, idempotency, rate limiting on simulation routes.Generated clients for CLI/UI; OpenAPI 3.1 document at /openapi/v1.json.
Observability (Telemetry/)Metrics (policy_run_seconds, policy_rules_fired_total, gate metrics), traces, structured logs.Sampled rule-hit logs with redaction.
Air-gap / offline adapter (AirGap/)Bundle import (PolicyPackBundleImportService), sealed-mode state (SealedModeService), staleness signaling, air-gap notifications, risk-profile air-gap export.Uses DSSE/trust-root verifier; signed bundles require an explicit trust-roots path or fail closed.
VEX Decision Emitter (Vex/)Build OpenVEX statements (VexDecisionEmitter), attach reachability evidence hashes, request DSSE signing (VexDecisionSigningService), proof-spine handling (VexProofSpineService), persist artifacts for Export Center / bench repo.Registered via AddVexDecisionEmitter() (POLICY-VEX-401-006); integrates with Signer predicate stella.ops/vexDecision@v1 and Attestor Rekor logging.
Determinization (StellaOps.Policy.Determinization/)Scores uncertainty/trust based on signal completeness and age; calculates entropy (0.0 = complete, 1.0 = no knowledge), confidence decay (exponential half-life), and aggregated trust scores; emits metrics for uncertainty/decay/trust; supports VEX-trust integration.Sibling library consumed by Signals and VEX subsystems; configuration via Determinization section.

3.1 · Determinization Configuration

The Determinization subsystem calculates uncertainty scores based on signal completeness (entropy), confidence decay based on observation age (exponential half-life), and aggregated trust scores. Configuration options in appsettings.json under Determinization:

{
  "Determinization": {
    "SignalWeights": {
      "VexWeight": 0.35,
      "EpssWeight": 0.10,
      "ReachabilityWeight": 0.25,
      "RuntimeWeight": 0.15,
      "BackportWeight": 0.10,
      "SbomLineageWeight": 0.05
    },
    "PriorDistribution": "Conservative",
    "ConfidenceHalfLifeDays": 14.0,
    "ConfidenceFloor": 0.1,
    "ManualReviewEntropyThreshold": 0.60,
    "RefreshEntropyThreshold": 0.40,
    "StaleObservationDays": 30.0,
    "EnableDetailedLogging": false,
    "EnableAutoRefresh": true,
    "MaxSignalQueryRetries": 3
  }
}
OptionTypeDefaultDescription
SignalWeightsObjectSee aboveRelative weights for each signal type in entropy calculation. Weights are normalized to sum to 1.0. VEX carries highest weight (0.35), followed by Reachability (0.25), Runtime (0.15), EPSS/Backport (0.10 each), and SBOM lineage (0.05).
PriorDistributionEnumConservativePrior distribution for missing signals. Options: Conservative (pessimistic), Neutral, Optimistic. Affects uncertainty tier classification when signals are unavailable.
ConfidenceHalfLifeDaysDouble14.0Half-life period for confidence decay in days. Confidence decays exponentially: exp(-ln(2) * age_days / half_life_days).
ConfidenceFloorDouble0.1Minimum confidence value after decay (0.0-1.0). Prevents confidence from decaying to zero, maintaining baseline trust even for very old observations.
ManualReviewEntropyThresholdDouble0.60Entropy threshold for triggering manual review (0.0-1.0). Findings with entropy ≥ this value require human intervention due to insufficient signal coverage.
RefreshEntropyThresholdDouble0.40Entropy threshold for triggering signal refresh (0.0-1.0). Findings with entropy ≥ this value should attempt to gather more signals before verdict.
StaleObservationDaysDouble30.0Maximum age before an observation is considered stale (days). Used in conjunction with decay calculations and auto-refresh triggers.
EnableDetailedLoggingBooleanfalseEnable verbose logging for entropy/decay/trust calculations. Useful for debugging but increases log volume significantly.
EnableAutoRefreshBooleantrueAutomatically trigger signal refresh when entropy exceeds RefreshEntropyThreshold. Requires integration with signal providers.
MaxSignalQueryRetriesInteger3Maximum retry attempts for failed signal provider queries before marking signal as unavailable.

Metrics emitted:

Usage in policies:

Determinization scores are exposed to SPL policies via the signals.trust.* and signals.uncertainty.* namespaces. Use signals.uncertainty.entropy to access entropy values and signals.trust.score for aggregated trust scores that combine VEX, reachability, runtime, and other signals with decay/weighting.

Weight Manifests:

EWS weights are externalized to versioned JSON manifests in etc/weights/. The unified score facade (IUnifiedScoreService) loads weights from these manifests rather than using compiled defaults, enabling auditable weight changes without code modifications. See Unified Score Architecture §4 for manifest schema and versioning rules.

3.1.1 · Trust Score Algebra Facade

The TrustScoreAlgebraFacade (ITrustScoreAlgebraFacade) provides a unified entry point composing TrustScoreAggregator + K4Lattice + ScorePolicy into a single deterministic scoring pipeline.

public interface ITrustScoreAlgebraFacade
{
    Task<TrustScoreResult> ComputeTrustScoreAsync(TrustScoreRequest request, CancellationToken ct);
    TrustScoreResult ComputeTrustScore(TrustScoreRequest request);
}

Pipeline steps:

  1. Calculate uncertainty entropy from signal snapshot
  2. Aggregate weighted signal scores via TrustScoreAggregator
  3. Compute K4 lattice verdict (Unknown/True/False/Conflict)
  4. Extract dimension scores (BaseSeverity, Reachability, Evidence, Provenance)
  5. Compute weighted final score in basis points (0-10000)
  6. Determine risk tier (Info/Low/Medium/High/Critical)
  7. Produce Score.v1 predicate for DSSE attestation

Score.v1 Predicate Format:

All numeric scores use basis points (0-10000) for bit-exact determinism:

{
  "predicateType": "https://stella-ops.org/predicates/score/v1",
  "artifactId": "pkg:maven/com.example/mylib@1.0.0",
  "vulnerabilityId": "CVE-2024-1234",
  "trustScoreBps": 7250,
  "tier": "High",
  "latticeVerdict": "True",
  "uncertaintyBps": 2500,
  "dimensions": {
    "baseSeverityBps": 5000,
    "reachabilityBps": 10000,
    "evidenceBps": 6000,
    "provenanceBps": 8000,
    "epssBps": 3500,
    "vexBps": 10000
  },
  "weightsUsed": {
    "baseSeverity": 1000,
    "reachability": 4500,
    "evidence": 3000,
    "provenance": 1500
  },
  "policyDigest": "sha256:abc123...",
  "computedAt": "2026-01-15T12:00:00Z",
  "tenantId": "tenant-123"
}

Risk Tier Mapping:

Score (bps)Tier
≥ 9000Critical
≥ 7000High
≥ 4000Medium
≥ 1000Low
< 1000Info

3.2 - License compliance configuration

License compliance evaluation runs during SBOM evaluation when enabled in licenseCompliance settings.

{
  "licenseCompliance": {
    "enabled": true,
    "policyPath": "policies/license-policy.yaml"
  }
}

3.3 - NTIA compliance configuration

NTIA minimum-elements validation runs when enabled under ntiaCompliance.

{
  "ntiaCompliance": {
    "enabled": true,
    "enforceGate": false,
    "policyPath": "policies/ntia-policy.yaml"
  }
}

4 · Data Model & Persistence

Policy Engine persists exclusively to a PostgreSQL policyschema (plus a policy_app role schema). There is no MongoDB store and no per-policy effective_finding_{policyId} collections — those were design-era artefacts. Schema is owned by StellaOps.Policy.Persistence and applied via forward-only startup migrations (AddStartupMigrations(PolicyDataSource.DefaultSchemaName, "Policy.Persistence", typeof(PolicyDataSource).Assembly) — schema policy, module name Policy.Persistence — wired in PolicyPersistenceExtensions:36 / AddPolicyPostgresStorage). Migration SQL files are embedded resources (Migrations\**\*.sql), with Migrations\_archived\** explicitly excluded from embedding. The live baseline 001_v1_policy_baseline.sql is idempotent (CREATE … IF NOT EXISTS) so it converges on reused local volumes.

4.1 Tables (by migration)

Migration-filename note (2026-07-12). The live migration set is 001_v1_policy_baseline.sql, 002_operator_decision_signing_columns.sql, 003_operator_decision_signing_view.sql, 004_risk_override_store.sql. The pre-1.0 numbering below (001_initial_schema.sql, 002015) is provenance of DDL folded into the baseline, not live filenames — those files sit under Migrations/_archived/pre_1.0/ (and .../mig061/) and are excluded from embedding. Note the leaf-name collision hazard: 001_initial_schema.sql exists in the archived tree only.

Consolidated baseline 001_v1_policy_baseline.sql (folds in the pre-1.0 001_initial_schema.sql):

(policy.risk_overrides is not in the baseline — it is created by the live post-baseline migration 004_risk_override_store.sql; see below.)

Folded-in pre-1.0 migrations (archived filenames — their DDL now lives inside the baseline):

Post-baseline forward-only migrations — these are the live 002004 files on disk (a separate physical sequence appended after the consolidated 001_v1_policy_baseline.sql; their numbers are distinct from the archived pre-1.0 numbering listed above, which is folded into the baseline). These add the operator-signed governance-decision support (ADR-025, sprint SPRINT_20260608_015):

All three are idempotent (CREATE TABLE/ADD COLUMN/CREATE INDEX … IF NOT EXISTS, DROP POLICY IF EXISTS + CREATE POLICY), embedded resources picked up by AddStartupMigrations, and converge on a fresh database (CLAUDE.md §2.7). Nothing is written under Migrations/_archived/**.

4.2 Schema Highlights

4.3 Indexing


5 · Evaluation Pipeline

sequenceDiagram
    autonumber
    participant Worker as EvaluationWorker
    participant Compiler as CompilerCache
    participant Selector as SelectionLayer
    participant Eval as Evaluator
    participant Mat as Materialiser
    participant Expl as ExplainStore

    Worker->>Compiler: Load IR (policyId, version, digest)
    Compiler-->>Worker: CompiledPolicy (cached or compiled)
    Worker->>Selector: Fetch tuple batches (sbom, advisory, vex)
    Selector-->>Worker: Deterministic batches (1024 tuples)
    loop For each batch
        Worker->>Eval: Execute rules (batch, env)
        Eval-->>Worker: Verdicts + rule hits
        Worker->>Mat: Upsert effective findings
        Mat-->>Worker: Success
        Worker->>Expl: Persist sampled explain traces (optional)
    end
    Worker->>Mat: Append history + run stats
    Worker-->>Worker: Compute determinism hash
    Worker->>+Mat: Finalize transaction
    Mat-->>Worker: Ack

Determinism guard instrumentation wraps the evaluator, rejecting access to forbidden APIs and ensuring batch ordering remains stable.

5.1 · Release-time findings lookup (ecosystem-aware PURL join)

PostgresFindingsLookup (src/Policy/StellaOps.Policy.Engine/Services/PostgresFindingsLookup.cs) resolves the deny-able CVE set for a release subject (release:<id>|env:<name>|...) from the operator-curated release_orchestrator.release_components rows plus SBOM-derived components fanned in from scanner.scan_evidence_projections.component_purls. Its advisory authority is selected by PolicyEngine:FindingsLookup:AdvisorySource: GateDecision (the Link-Not-Merge gate projection in vuln.issue_gate_decisions, now authoritative by default) or Legacy (the one-release rollback path reading vuln.advisory_affected / vuln.advisories). PolicyEngine:FindingsLookup:ShadowCompare=true evaluates the non-authoritative source too and emits deterministic legacy-only, gate-decision-only, and metadata-changed classifications without changing the verdict. Shadow failure is loud in logs but cannot replace or weaken the configured authoritative result. The compose operator controls are POLICY_FINDINGS_ADVISORY_SOURCE (default GateDecision) and POLICY_FINDINGS_SHADOW_COMPARE (default true during the compatibility window); they bind through the service-prefixed configuration keys and require a Policy container recreate to change. Roll back by setting the source to Legacy; do not remove that selector until the compatibility release closes.

The gate-decision query uses migration 021’s normalized package_name and ecosystem keys and passes effective_affected_range through the same shared EcosystemVersionFilter used by the legacy path. not_affected, withdrawn, and rejected decisions are always suppressed. fixed and resolved suppress only range-less decisions: when an affected range exists they mean that patched versions exist, while installed versions inside that range remain vulnerable. The shared GateDecisionStatusSemantics contract keeps Policy and compact export aligned. Thus source selection changes precedence authority, not PURL bucket or version-comparison semantics. The 2026-07-15 CANON-D6 parity, benchmark, and live forcing-function gates closed with zero unexplained recall regression before the default changed.

Sprint 20260531.036 / D1.1 — ecosystem-aware join (read-side). Until this sprint the SQL join was lower(af.package_name) = ANY(@packages) — text-only on the package name. That collapsed same-name-different-ecosystem packages into the same advisory bucket and produced FALSE-POSITIVE / FALSE-NEGATIVE matches:

Component shapeAdvisory shapePre-036 resultPost-036 result
Alpine openssl 3.1.4-r1 (apk)Debian openssl (deb)FALSE-POSITIVE matchno match
Debian libssl1.1 (deb)Alpine openssl (apk)FALSE-POSITIVE matchno match
npm @scope/pkgnpm short pkgFALSE-POSITIVE matchno match
Maven org.apache.commons:commons-lang3short commons-lang3 (maven)FALSE-POSITIVE matchno match
PyPi DjangoDebian python3-djangoFALSE-POSITIVE matchno match
OCI base image redisPyPi redisFALSE-POSITIVE matchno match

Contract after Sprint 20260531.036:

  1. Producer (PostgresReleaseTruthRepository.UpsertComponentAsync) writes two new nullable columns to every release_components row: package_purl (canonical PURL string) + package_ecosystem (lowercased PURL type — apk, deb, npm, maven, pypi, oci, …). OCI image components default to package_ecosystem = "oci" and a synthetic PURL pkg:oci/<image-ref>@<digest>. The columns are NULLABLE; legacy rows that pre-date the migration fall back to a name-only match (logged as policy.findings_lookup.legacy_name_match).
  2. Release Orchestrator persistence adds the columns + partial indexes (ix_release_components_purl / ix_release_components_ecosystem_name) in its consolidated baseline src/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Migrations/001_v1_releaseorchestrator_baseline.sql:905 (folded in from the archived 014_release_components_purl_ecosystem.sql). Forward-only per ADR-004. Re-runnable.
  3. Consumer (PostgresFindingsLookup) runs TWO SQL passes for either configured advisory source:
    • Scoped pass — fires only when at least one component carries a canonical ecosystem; SQL has both lower(af.package_name) = ANY(@packages) AND lower(af.ecosystem) = ANY(@ecosystems). @ecosystems is the FAN-OUT of every equivalent spelling per EcosystemEquivalence (so an advisory tagged "alpine" still matches a component typed apk, and vice versa).
    • Legacy pass — fires only when at least one component has NO resolvable ecosystem; SQL is name-only. A component WITH an ecosystem is NEVER consumed via this pass (would re-introduce the false-positive class).
  4. EcosystemEquivalence.Canonicalisemaps {"alpine","apk"} → "apk", {"debian","ubuntu","deb"} → "deb", {"redhat","rhel","centos","fedora","amzn","suse","opensuse","rpm"} → "rpm", {"go","golang"} → "golang", {"docker","oci","container"} → "oci", etc. The EquivalentSpellings() reverse lookup is what backs the SQL ANY(@ecosystems) parameter.
  5. EcosystemAwareJoinPlan (src/__Libraries/StellaOps.VulnMatch.Core/EcosystemAwareJoinPlan.cs) is the pure-function planner extracted from PostgresFindingsLookup so the bucket-key + ecosystem fan-out semantics are unit-testable without a live PostgreSQL fixture. It lives in the shared StellaOps.VulnMatch.Core library (alongside EcosystemEquivalence, same directory), not under StellaOps.Policy.Engine/Services; PostgresFindingsLookup merely consumes it. The runtime delegates bucket construction + advisory-row admission to it.
  6. PURL name shapes per ecosystem (preserved by PurlComponentExtractor.TryParseFull):
    • npm scopedpkg:npm/%40babel/core@7.0.0 / pkg:npm/@scope/widget@1.0.0 → name @scope/widget.
    • Mavenpkg:maven/org.apache.commons/commons-lang3@3.12.0 → name org.apache.commons:commons-lang3 (groupId+artifactId joined with :).
    • Golangpkg:golang/github.com/foo/bar@v1.2.3 → name github.com/foo/bar (full module path).
    • composer / pub / swiftpkg:composer/api-platform/core@v3.4.17 → name api-platform/core (the vendor namespace is preserved). Collapsing to the bare core cross-matched every vendor’s */core advisory (e.g. drupal/core vs api-platform/core), the ~460 false-positive class closed by the composer-namespace precision fix. Single-segment composer/pub/swift PURLs keep their bare name. AdvisoryConverter derives vuln.advisory_affected.package_name from the SAME extractor, so the deployed and advisory keys never drift; the live Concelier migration src/Concelier/__Libraries/StellaOps.Concelier.Persistence/Migrations/009_composer_namespaced_package_name_backfill.sql re-derives existing rows from their retained PURL.
    • Everything else (apk/deb/rpm/pypi/nuget/oci/generic) — only the trailing path segment.

The post-hoc EcosystemVersionFilter C# pass remains in place as a secondary version-range discriminator; it is no longer the only ecosystem-awareness layer.


6 · Run Orchestration & Incremental Flow


6.1 · VEX decision attestation pipeline

  1. Verdict capture. Each evaluation result contains {findingId, cve, productKey, reachabilityState, evidenceRefs} plus SBOM and runtime CAS hashes.
  2. OpenVEX serialization. VexDecisionEmitter builds an OpenVEX document with one statement per (cve, productKey) and fills:
    • status, justification, status_notes, impact_statement, action_statement.
    • products (purl) and evidence array referencing reachability.json, sbom.cdx.json, runtimeFacts.
  3. DSSE signing. The emitter calls Signer POST /api/v1/signer/sign/dsse with predicate stella.ops/vexDecision@v1. Signer verifies PoE + scanner integrity and returns a DSSE envelope (decision.dsse.json).
  4. Transparency (optional). When Rekor integration is enabled, Attestor logs the DSSE payload and returns {uuid, logIndex, checkpoint} which we persist next to the decision. Hosts that do not configure Rekor anchoring must fail truthfully with an unsupported submission path; they must never fabricate a successful transparency receipt.
  5. Export. API/CLI endpoints expose decision.openvex.json, decision.dsse.json, rekor.txt, and evidence metadata so Export Center + bench automation can mirror them into bench/findings/** as defined in the VEX Evidence Playbook.

Policy-owned evidence consumers fail closed when required trust material cannot be verified. VEX verification rejects base64-only signatures unless an explicit local development signing mode is enabled for tests. VEX proof gates also reject incomplete proof metadata: required confidence tier, statement count, signed-statement status, proof timestamp, DSSE envelope digest, and Rekor log index must be present when the policy asks for them. PoE validation rejects required DSSE and Rekor evidence until a real verifier/trust-root integration is configured. CVSS receipt creation enforces declared DSSE, Rekor, signer, and trust-level requirements instead of silently producing unsigned receipts. Policy pack bundle import and sealed-mode bundle verification do not infer trust from signature metadata alone: signed bundles require an explicit trust-roots path plus a configured DSSE/trust-root verifier, and absent verifier/trust roots or verifier rejection leaves the import failed or the verification response invalid.

Replay, Unknowns, what-if, attestation verification, and overlay publication share the same truthfulness rule. The Unknowns gate queries the configured Unknowns API and blocks with unknowns_source_unavailable when that source is unavailable; it must not synthesize HOT unknown IDs or random unknown states. Replay returns ReplayFailed when no replay evaluator and original verdict store are configured; hash-derived verdicts are forbidden. What-if simulation requires an explicit baseline policy pack and fails unavailable until a real evaluator is wired for hypothetical SBOM diffs and draft policies. Policy decision attestation verification returns invalid/unavailable without a DSSE verifier and attestation store, and overlay change publication fails when only the log sink is registered rather than a durable event bus.

All payloads are immutable and include analyzer fingerprints (scanner.native@sha256:..., policyEngine@sha256:...) so replay tooling can recompute identical digests. Determinism tests cover both the OpenVEX JSON and the DSSE payload bytes.


6.2 · CI/CD Release Gate API

The Policy Engine exposes a gate evaluation API for CI/CD pipelines to validate images before deployment.

Gate Endpoint

POST /api/v1/policy/gate/evaluate

Request (GateEvaluateRequest; only imageDigest is required; tenant is resolved from the envelope-bound stellaops:tenant claim, not a request field):

{
  "imageDigest": "sha256:abc123def456",
  "repository": "registry.example.com/app",
  "tag": "1.2.0",
  "baselineRef": "sha256:baseline789",
  "missingBaselineMode": "block",
  "policyId": "production-gate",
  "allowOverride": false,
  "overrideJustification": null,
  "source": "cli",
  "ciContext": "github-actions",
  "context": {
    "branch": "main",
    "commitSha": "abc123",
    "pipelineId": "run-456",
    "environment": "production",
    "actor": "deploy-service@example.com"
  }
}

Response (GateEvaluateResponse; status is the GateStatus enum Pass|Warn|Fail; severity deltas live under the optional deltaSummary object, not as top-level counts):

{
  "decisionId": "decision-uuid",
  "status": "Pass",
  "exitCode": 0,
  "imageDigest": "sha256:abc123def456",
  "baselineRef": "sha256:baseline789",
  "baselineStatus": "resolved",
  "evidence": { "verdictHash": "sha256:..." },
  "decidedAt": "2025-12-26T12:00:00Z",
  "summary": "No new critical vulnerabilities",
  "advisory": null,
  "gates": [
    { "name": "CvssThresholdGate", "result": "pass", "reason": "below threshold" }
  ],
  "blockedBy": null,
  "blockReason": null,
  "suggestion": null,
  "overrideApplied": false,
  "deltaSummary": {
    "totalChanges": 0,
    "riskIncreasing": 0,
    "riskDecreasing": 0,
    "neutral": 0,
    "riskScore": 0,
    "riskDirection": "none"
  }
}

Gate Status Values

StatusExit CodeDescription
Pass0No blocking issues; safe to deploy
Warn1Non-blocking issues detected; configurable pass-through
Fail2Blocking issues; deployment should be halted

Every gate response is durably recorded in policy.gate_decisions after the response object is constructed. If the public decisionId is not UUID-form, Policy derives a stable UUID for the history table from the decision id, digest, status, and decision timestamp while preserving the public string decision id in the API response. The request image digest is stored in image_digest, and Integrations reads the latest row per tenant/digest to project registry-browser hasDecision/verdict fields.

Webhook Integration

The Policy Gateway accepts webhooks from container registries for automated gate evaluation:

Docker Registry v2:

POST /api/v1/webhooks/registry/docker

Harbor:

POST /api/v1/webhooks/registry/harbor

Generic (Zastava events):

POST /api/v1/webhooks/registry/generic
Webhook signature contract (Sprint 001 POLICY-WHK-03…05; wired by SPRINT_017)

All three registry webhook endpoints are HMAC-only. Unsigned requests are rejected with HTTP 401. There is no bearer/cookie auth on these routes — the per-registry shared secret is the sender proof. The signature filter is wired via RegistryWebhookSignatureFilter and validated through the regional crypto plug-in interface StellaOps.Cryptography.IHmacAlgorithm, so FIPS / GOST / SM / eIDAS deployments substitute their compliant implementation without source edits to the gateway.

EndpointHeaderAlgorithm
/dockerX-StellaOps-Signature: sha256=<hex> (preferred) or Docker Authorization: Basic <user:hex>HMAC-SHA256 over raw body
/harborX-Harbor-Signature: sha256=<hex>HMAC-SHA256 over raw body
/genericX-StellaOps-Signature: sha256=<hex> (optional X-StellaOps-Key-Id)HMAC-SHA256 over raw body

Rejection reasons (returned in the problem+json detail on 401) include validator_disabled, secret_not_configured, missing_signature_header, signature_scheme_unsupported, signature_malformed, signature_invalid, bearer_not_supported, unknown_key_id, missing_key_id. Operators MUST NOT expose secret material in problem responses; the validators only emit the stable reason token.

Configuration. Operators set the per-registry shared secret in an environment variable referenced by Policy:Gateway:RegistryWebhooks:<Kind>:SharedSecret:EnvVar. The compose defaults reference POLICY_REGISTRY_WEBHOOK_DOCKER_SECRET, POLICY_REGISTRY_WEBHOOK_HARBOR_SECRET, and POLICY_REGISTRY_WEBHOOK_GENERIC_SECRET; populating those env vars from a sealed-secret / Vault / SOPS source is the recommended deployment path. The AllowUnsigned rollout toggle (Policy:Gateway:RegistryWebhooks:AllowUnsigned) exists only for migration; RegistryWebhookProductionGuard fails the host at startup if AllowUnsigned=true while ASPNETCORE_ENVIRONMENT=Production.

Body buffering. The gateway pipeline calls UseRegistryWebhookBodyBuffering() early (before authentication middleware) so that the signature filter — which fires after minimal-API parameter binding — can rewind the request body and hash the original bytes. Without that middleware, in current ASP.NET Core minimal-API pipelines the [FromBody] binder drains the body before any endpoint filter runs, the filter sees zero bytes, and the HMAC always fails.

Operator runbook. Per-registry sender-side wiring recipes (Docker notifications.endpoints[].headers, Harbor webhook auth header, generic curl), failure-mode reason tokens, and the migration path for unsigned deployments live at docs/ops/policy-registry-webhooks.md.

Webhook push handlers now use a runtime-selected async gate-evaluation path. When Postgres:Scheduler is not configured, both Policy hosts resolve IGateEvaluationQueue to an explicit unsupported runtime adapter and return 501 problem+json instead of fabricating queued work. When scheduler persistence is configured, both hosts register the shared scheduler-backed queue runtime, auto-migrate the scheduler schema, enqueue deduplicated policy.gate-evaluation jobs, dispatch them through the worker service, and expose persisted request/decision status from GET /api/v1/policy/gate/jobs/{jobId}. The previous process-local InMemoryGateEvaluationQueue and background worker path was removed because it fabricated “no drift” gate contexts and fake job IDs instead of dispatching real work.

Gate Bypass Auditing

Bypass attempts are logged to policy.gate_bypass_audit:

Runtime Snapshots And Ledger Exports

{
  "bypassId": "bypass-uuid",
  "imageDigest": "sha256:abc123",
  "actor": "deploy-service@example.com",
  "justification": "Emergency hotfix - JIRA-12345",
  "ipAddress": "10.0.0.100",
  "ciContext": {
    "provider": "github-actions",
    "runId": "12345678",
    "workflow": "deploy.yml"
  },
  "createdAt": "2025-12-26T12:00:00Z"
}

CLI Integration

# Evaluate gate
stella gate evaluate --image sha256:abc123 --baseline sha256:baseline

# Check gate status (looks up a previous synchronous decision by its decisionId)
stella gate status --decision-id <decision-id>

# Override with justification
stella gate evaluate --image sha256:abc123 \
  --allow-override \
  --justification "Emergency hotfix approved by CISO - JIRA-12345"

See also: CI/CD Gate Workflows, Keyless Signing Guide


6.3 · Trust Lattice Policy Gates

The Policy Engine evaluates Trust Lattice gates after claim score merging to enforce trust-based constraints on VEX verdicts.

Gate Interface

public interface IPolicyGate
{
    Task<GateResult> EvaluateAsync(
        MergeResult mergeResult,
        PolicyGateContext context,
        CancellationToken ct = default);
}

public sealed record GateResult
{
    public required string GateName { get; init; }
    public required bool Passed { get; init; }
    public string? Reason { get; init; }
    public ImmutableDictionary<string, object> Details { get; init; }
}

Available Gates

GatePurposeConfiguration Key
MinimumConfidenceGateReject verdicts below confidence threshold per environmentgates.minimumConfidence
UnknownsBudgetGateFail scan if unknowns exceed budgetgates.unknownsBudget
SourceQuotaGatePrevent single-source dominance without corroborationgates.sourceQuota
ReachabilityRequirementGateRequire reachability proof for critical CVEsgates.reachabilityRequirement
EvidenceFreshnessGateReject stale evidence below freshness thresholdgates.evidenceFreshness
CvssThresholdGateBlock findings above CVSS score thresholdgates.cvssThreshold
SbomPresenceGateRequire valid SBOM for release artifactsgates.sbomPresence
SignatureRequiredGateRequire signatures on specified evidence typesgates.signatureRequired
FacetQuotaGateEnforce per-facet drift quotas from a pre-computed FacetDriftReport (block/warn/seal-action when report absent)FacetQuotaGateOptions
FixChainGateRequire fix-chain verification for critical vulnerabilities (see Fix Chain Gates)FixChainGateOptions
VexProofGateValidate VEX proof objects meet policy requirements (confidence tier, signed-statement status, DSSE digest, Rekor index)VexProofGateOptions

All eleven gates implement IPolicyGate and live under src/Policy/__Libraries/StellaOps.Policy/Gates/. The five threshold/presence gates and MinimumConfidence/Unknowns/SourceQuota/Reachability/EvidenceFreshness are covered by etc/policy-gates.yaml.sample; FacetQuotaGate, FixChainGate, and VexProofGate bind their own options classes and are not yet represented in that sample file.

MinimumConfidenceGate

Requires minimum confidence threshold for suppression verdicts:

gates:
  minimumConfidence:
    enabled: true
    thresholds:
      production: 0.75    # High bar for production
      staging: 0.60       # Moderate for staging
      development: 0.40   # Permissive for dev
    applyToStatuses:
      - not_affected
      - fixed

UnknownsBudgetGate

Limits exposure to unknown/unscored dependencies:

gates:
  unknownsBudget:
    enabled: true
    maxUnknownCount: 5
    maxCumulativeUncertainty: 2.0
    escalateOnExceed: true

SourceQuotaGate

Prevents single-source verdicts without corroboration:

gates:
  sourceQuota:
    enabled: true
    maxInfluencePercent: 60
    corroborationDelta: 0.10
    requireCorroboration: true

ReachabilityRequirementGate

Requires reachability proof for high-severity vulnerabilities:

gates:
  reachabilityRequirement:
    enabled: true
    applySeverities:
      - critical
      - high
    exemptStatuses:
      - not_affected
    bypassReasons:
      - component_not_present

CvssThresholdGate

Sprint: SPRINT_20260112_017_POLICY_cvss_threshold_gate

Blocks findings above a configurable CVSS score threshold per environment:

gates:
  cvssThreshold:
    enabled: true
    priority: 15
    defaultThreshold: 7.0
    thresholds:
      production: 7.0
      staging: 8.0
      development: 9.0
    cvssVersionPreference: highest  # v3.1, v4.0, or highest
    failOnMissingCvss: false
    requireAllVersionsPass: false
    allowlist:
      - CVE-2024-XXXXX  # False positive
    denylist:
      - CVE-2024-YYYYY  # Always block

SbomPresenceGate

Sprint: SPRINT_20260112_017_POLICY_sbom_presence_gate

Requires valid SBOM presence for release artifacts:

gates:
  sbomPresence:
    enabled: true
    priority: 5
    enforcement:
      production: required
      staging: required
      development: optional
    acceptedFormats:
      - spdx-2.3
      - spdx-3.0.1
      - cyclonedx-1.5
      - cyclonedx-1.6
    minimumComponents: 1
    requireSignature: true
    schemaValidation: true
    requirePrimaryComponent: true

SignatureRequiredGate

Sprint: SPRINT_20260112_017_POLICY_signature_required_gate

Requires cryptographic signatures on specified evidence types:

gates:
  signatureRequired:
    enabled: true
    priority: 3
    evidenceTypes:
      sbom:
        required: true
        trustedIssuers:
          - "*@company.com"
          - "build-service@ci.example.com"
        acceptedAlgorithms:
          - ES256
          - RS256
      vex:
        required: true
        trustedIssuers:
          - "*@vendor.com"
      attestation:
        required: true
    enableKeylessVerification: true
    fulcioRoots: /etc/stella/trust/fulcio-roots.pem
    requireTransparencyLogInclusion: true
    environments:
      development:
        requiredOverride: false
        skipEvidenceTypes:
          - sbom

Gate Registry

Gates are registered via DI and evaluated in sequence:

public interface IPolicyGateRegistry
{
    IEnumerable<IPolicyGate> GetEnabledGates(string environment);
    Task<GateEvaluationResult> EvaluateAllAsync(
        MergeResult mergeResult,
        PolicyGateContext context,
        CancellationToken ct = default);
}

Gate Metrics

PolicyEngineTelemetry emits gate-job SLI instruments from the async gateway executor:

Gate Implementation Reference

GateSource File
MinimumConfidenceGatesrc/Policy/__Libraries/StellaOps.Policy/Gates/MinimumConfidenceGate.cs
UnknownsBudgetGatesrc/Policy/__Libraries/StellaOps.Policy/Gates/UnknownsBudgetGate.cs
SourceQuotaGatesrc/Policy/__Libraries/StellaOps.Policy/Gates/SourceQuotaGate.cs
ReachabilityRequirementGatesrc/Policy/__Libraries/StellaOps.Policy/Gates/ReachabilityRequirementGate.cs
EvidenceFreshnessGatesrc/Policy/__Libraries/StellaOps.Policy/Gates/EvidenceFreshnessGate.cs
CvssThresholdGatesrc/Policy/__Libraries/StellaOps.Policy/Gates/CvssThresholdGate.cs
SbomPresenceGatesrc/Policy/__Libraries/StellaOps.Policy/Gates/SbomPresenceGate.cs
SignatureRequiredGatesrc/Policy/__Libraries/StellaOps.Policy/Gates/SignatureRequiredGate.cs
FacetQuotaGatesrc/Policy/__Libraries/StellaOps.Policy/Gates/FacetQuotaGate.cs
FixChainGatesrc/Policy/__Libraries/StellaOps.Policy/Gates/FixChainGate.cs
VexProofGatesrc/Policy/__Libraries/StellaOps.Policy/Gates/VexProofGate.cs

See etc/policy-gates.yaml.sample for the configuration options covered by that sample (the three additional gates above bind their own options classes).

Related Documentation:


6.4 · Exception Approval Workflow

The Policy Engine provides a role-based exception approval workflow for gate overrides, allowing teams to request time-limited exceptions with proper audit trails.

Exception Model

public enum ApprovalRequestStatus
{
    Pending,     // Awaiting approvals
    Partial,     // Some approvals received, not yet complete
    Approved,    // All required approvals obtained
    Rejected,    // At least one rejection
    Expired,     // TTL exceeded without full approval
    Cancelled    // Requestor cancelled
}

public enum ExceptionReasonCode
{
    FalsePositive,         // CVE does not apply to this usage
    AcceptedRisk,          // Risk accepted with compensating controls
    CompensatingControl,   // Mitigation in place (WAF, network isolation)
    TestOnly,              // Affects only test/non-shipping code paths
    VendorNotAffected,     // Vendor VEX states product not affected
    ScheduledFix,          // Fix scheduled within TTL window
    DeprecationInProgress, // Component being removed/replaced
    RuntimeMitigation,     // Mitigated at runtime (e.g. sandbox, seccomp)
    NetworkIsolation,      // Reachability removed via network isolation
    Other                  // Other documented justification
}

Gate-Level Approval Requirements

Gate LevelDescriptionRequired Approvers
G0DevelopmentAuto-approve (no approval needed)
G1Testing/QA1 peer reviewer
G2StagingCode owner approval
G3Pre-productionDelivery Manager + Product Manager
G4ProductionCISO + Delivery Manager + Product Manager

Exception Request Endpoint

POST /api/v1/policy/exception/request

See Implementation Reference below for full API contract details.

Operator signing-key readiness (OSK-P1)

GET /api/v1/policy/exception/operator-key/status resolves the authenticated operator’s current DecisionSigning keys through IssuerDirectory and returns a provider-compatible readiness result plus enrollmentState (pending, enrolled, or re_enroll_required) and the stable enrolment route /administration/profile. The read is tenant- and subject-bound, filters to active keys inside their validity window, plus a provider-change-retired key strictly before its future retiredAt grace cutoff, and verifies algorithm support through ICryptoProviderRegistry; Policy does not perform bare cryptography. A grace key resolves through its enrolled providerHint, may satisfy request readiness, and still projects re_enroll_required rather than enrolled. At or after the cutoff it no longer satisfies readiness. No key history, or a key awaiting approval, projects pending; a compatible current key projects enrolled; incompatible, non-grace retired, revoked, or expired history projects re_enroll_required. A missing key is an available status with hasKey=false. Missing IssuerDirectory client or issuer configuration, and dependency failures, return 503 fail-closed instead of being misreported as a keyless operator.

OSK-P2 removed the transitional Policy:ExceptionApprovalSigning:ProviderHint source from enrollment and response decisions. Policy now calls Platform’s tenant-bound /api/v1/admin/crypto-providers/compliance-profile/operator-signing-enrollment projection, forwards the tenant and bearer identity, and uses its exact provider and algorithm when checking active IssuerDirectory keys. Exception queue and approval-request DTO signing metadata use that same live provider. An unavailable or incomplete Platform projection fails closed (503) instead of falling back to a stale static hint. OSK-P3 adds the pending | enrolled | re_enroll_required projection; none of these read paths enables signing-required behavior by itself.

The production enforcement source is Policy:OperatorSigningRequirement:Tenants:<tenant>=true; the map is empty by default and has no global enable. When required, POST /api/v1/policy/exception/request checks key presence before validation or persistence and returns HTTP 400 with code=needs-enrollment, the active provider, and /administration/profile for a keyless operator. Dependency failure returns 503 instead of bypassing the gate. If the rules would auto-approve a request into a grant, the request must include a valid operator-decision@v1 envelope bound to artifactDigest; unsigned grants are rejected and a verified envelope is persisted with the approved request.

Approval Actions

Approve:

POST /api/v1/policy/exception/{requestId}/approve

Reject:

POST /api/v1/policy/exception/{requestId}/reject

Approval → grant materialization (2026-07-21, I3-WAIVER-EXCEPTION-FLOW)

An approval request that reaches Approved (final M-of-N approve, or an eligible auto-approve at create time) is materialized into an ACTIVE ExceptionObjectby ExceptionApprovalMaterializer (src/Policy/StellaOps.Policy.Engine/Services/ExceptionApprovalMaterializer.cs): the grant is created through the real lifecycle (CreatedActivated events in policy.exception_events), scoped from the request (vulnerability_id/purl_pattern/artifact_digest/environments), expires at the request’s exception_expires_at, carries metadata.approvalRequestId for provenance, and is back-linked onto the request row (exception_id, returned in the create/approve DTOs). Before this bridge the two stores were disjoint — an approved EAR was a signed record that neither policy evaluation (ExceptionAdapter) nor the Findings exposure reader (which consume policy.exceptions only) ever honored. Materialization failure after a recorded approval returns an explicit 500 naming the state (approval recorded, grant missing) rather than a silent success. The console’s per-finding Waivers/Exceptions tab (Vulnerabilities pillar) creates requests via POST /api/v1/policy/exception/request (scope exceptions:request, granted to stella-ops-ui by Authority migration 021) and lists them via GET /api/v1/policy/exception/requests.

Audit Trail

All exception actions are logged to policy.exception_approval_audit with actor, timestamp, and IP address.

TTL Enforcement

CLI Integration

# Request exception
stella exception request --cve CVE-2024-12345 --purl "pkg:npm/lodash@4.17.20" --reason-code CompensatingControl --rationale "WAF rules deployed" --ttl 30 --gate-level G3 --ticket JIRA-12345

# Approve exception
stella exception approve <request-id> --comment "Verified controls"

# Reject exception
stella exception reject <request-id> --reason "Insufficient mitigation"

# List pending approvals
stella exception list --status pending

# Check status
stella exception status <request-id>

Implementation Reference

ComponentSource File
Entitiessrc/Policy/__Libraries/StellaOps.Policy.Persistence/Postgres/Models/ExceptionApprovalEntity.cs
Repositorysrc/Policy/__Libraries/StellaOps.Policy.Persistence/Postgres/Repositories/ExceptionApprovalRepository.cs
Rules Servicesrc/Policy/StellaOps.Policy.Engine/Services/ExceptionApprovalRulesService.cs
API Endpoints (live, merged host)src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/ExceptionApprovalEndpoints.cs (mapped via app.MapExceptionApprovalEndpoints() in Program.cs)
API Endpoints (standalone gateway)src/Policy/StellaOps.Policy.Gateway/Endpoints/ExceptionApprovalEndpoints.cs
CLI Commandssrc/Cli/StellaOps.Cli/Commands/ExceptionCommandGroup.cs
Migrationpolicy.exception_approval_requests / _audit / _rules tables in src/Policy/__Libraries/StellaOps.Policy.Persistence/Migrations/001_v1_policy_baseline.sql (lines 730 / 770 / 789). Both the earlier 013_exception_approval.sql and the pre-1.0 001_initial_schema.sql are archived under Migrations/_archived/pre_1.0/ and are not embedded.

Related Documentation:

Governance Compatibility Endpoints

The console governance workspaces still target a tenant-scoped compatibility surface under /api/v1/governance/*, but that surface is now explicitly split between persisted read paths and truthful unsupported behavior.

Contract requirements:

Implementation reference:


7 · Security & Tenancy

Determinism enforcement (DOCS-POLICY-DET-01)


8 · Observability

Metrics are emitted from StellaOps.Policy.Engine/Telemetry/PolicyEngineTelemetry.cs (consult the source for the authoritative tag set). Representative instruments:


9 · Offline / Bundle Integration


10 · Testing & Quality


11 · Compliance Checklist


The following product advisories provide strategic context for Policy Engine features:


13 · Policy Interop Layer

Sprint: SPRINT_20260122_041_Policy_interop_import_export_rego

The Interop Layer provides bidirectional policy exchange between Stella’s native C# gate engine and OPA/Rego. The C# engine remains primary; Rego serves as an interoperability adapter for teams using OPA-based toolchains.

13.1 · Supported Formats

FormatSchemaDirectionNotes
PolicyPack v2 (JSON)policy.stellaops.io/v2Import + ExportCanonical format with typed gates, environment overrides, remediation hints
PolicyPack v2 (YAML)policy.stellaops.io/v2Import + ExportDeterministic YAML with sorted keys; YAML→JSON roundtrip for validation
OPA/Regopackage stella.releaseExport (+ Import with pattern matching)Deny-by-default pattern, remediation output rules

13.2 · Architecture

graph TD
    subgraph Interop["StellaOps.Policy.Interop"]
        Exporter[JsonPolicyExporter / YamlPolicyExporter / RegoPolicyExporter]
        Importer[JsonPolicyImporter / YamlPolicyImporter / RegoPolicyImporter]
        DiffMerge[PolicyDiffMergeEngine]
        Validator[PolicySchemaValidator]
        Generator[RegoCodeGenerator]
        Resolver[RemediationResolver]
        OPA[EmbeddedOpaEvaluator]
        Detector[FormatDetector]
    end
    subgraph Consumers
        CLI[stella policy export/import/validate/evaluate/diff/merge]
        API[Policy Engine API /api/v1/policy/interop]
        UI[Policy Editor UI]
    end

    CLI --> Exporter
    CLI --> Importer
    CLI --> Validator
    CLI --> DiffMerge
    API --> Exporter
    API --> Importer
    API --> Validator
    API --> DiffMerge
    UI --> API

    Exporter --> Generator
    Exporter --> Resolver
    Importer --> Detector
    Importer --> OPA
    Generator --> Resolver

13.3 · Gate-to-Rego Translation

Each C# gate type maps to a Rego deny rule pattern:

Gate TypeRego PatternRemediation Code
CvssThresholdGateinput.cvss.score >= thresholdCVSS_EXCEED
SignatureRequiredGatenot input.dsse.verifiedSIG_MISS
EvidenceFreshnessGatenot input.freshness.tstVerifiedFRESH_EXPIRED
SbomPresenceGatenot input.sbom.canonicalDigestSBOM_MISS
MinimumConfidenceGateinput.confidence < thresholdCONF_LOW
UnknownsBudgetGateinput.unknownsRatio > thresholdUNK_EXCEED
ReachabilityRequirementGatenot input.reachability.statusREACH_REQUIRED

13.4 · Remediation Hints

When a gate blocks, the system resolves structured remediation hints:

Priority: Gate-defined hint > Built-in defaults > null

RemediationHint:
  Code:        Machine-readable (e.g., "CVSS_EXCEED")
  Title:       Human-readable summary
  Actions[]:   CLI command templates with {placeholders}
  References:  External documentation links
  Severity:    critical | high | medium | low

Placeholders ({purl}, {image}, {reason}) are resolved via RemediationContext at evaluation time.

13.5 · Determinism

All exports and evaluations are deterministic:

13.6 · YAML Format Support

Sprint: SPRINT_20260208_048_Policy_policy_interop_framework

YAML export/import operates on the same PolicyPackDocument model as JSON. The YAML format is useful for human-editable policy files and GitOps workflows.

Export (YamlPolicyExporter : IPolicyYamlExporter):

Import (YamlPolicyImporter):

Format Detection (FormatDetector):

13.7 · Policy Diff/Merge Engine

Sprint: SPRINT_20260208_048_Policy_policy_interop_framework

The PolicyDiffMergeEngine (IPolicyDiffMerge) compares and merges PolicyPackDocument instances structurally.

Diff produces PolicyDiffResult containing:

Merge applies one of three strategies via PolicyMergeStrategy:

StrategyBehavior
OverlayWinsOverlay values take precedence on conflict
BaseWinsBase values take precedence on conflict
FailOnConflictReturns error with conflict details

Merge output includes the merged PolicyPackDocument and a list of PolicyMergeConflict items (path, base value, overlay value).

13.8 · Implementation Reference

ComponentSource File
Contractssrc/Policy/__Libraries/StellaOps.Policy.Interop/Contracts/PolicyPackDocument.cs
Remediation Modelssrc/Policy/__Libraries/StellaOps.Policy.Interop/Contracts/RemediationModels.cs
Interfacessrc/Policy/__Libraries/StellaOps.Policy.Interop/Abstractions/
JSON Exportersrc/Policy/__Libraries/StellaOps.Policy.Interop/Export/JsonPolicyExporter.cs
YAML Exportersrc/Policy/__Libraries/StellaOps.Policy.Interop/Export/YamlPolicyExporter.cs
JSON Importersrc/Policy/__Libraries/StellaOps.Policy.Interop/Import/JsonPolicyImporter.cs
YAML Importersrc/Policy/__Libraries/StellaOps.Policy.Interop/Import/YamlPolicyImporter.cs
Rego Generatorsrc/Policy/__Libraries/StellaOps.Policy.Interop/Rego/RegoCodeGenerator.cs
Rego Importersrc/Policy/__Libraries/StellaOps.Policy.Interop/Import/RegoPolicyImporter.cs
Diff/Merge Enginesrc/Policy/__Libraries/StellaOps.Policy.Interop/DiffMerge/PolicyDiffMergeEngine.cs
Embedded OPAsrc/Policy/__Libraries/StellaOps.Policy.Interop/Evaluation/EmbeddedOpaEvaluator.cs
Remediation Resolversrc/Policy/__Libraries/StellaOps.Policy.Interop/Evaluation/RemediationResolver.cs
Format Detectorsrc/Policy/__Libraries/StellaOps.Policy.Interop/Import/FormatDetector.cs
Schema Validatorsrc/Policy/__Libraries/StellaOps.Policy.Interop/Validation/PolicySchemaValidator.cs
DI Registrationsrc/Policy/__Libraries/StellaOps.Policy.Interop/DependencyInjection/PolicyInteropServiceCollectionExtensions.cs
CLI Commandssrc/Cli/StellaOps.Cli/Commands/Policy/PolicyInteropCommandGroup.cs
Policy Engine APIsrc/Policy/StellaOps.Policy.Engine/Interop/Endpoints/PolicyInteropEndpoints.cs
JSON Schemadocs/schemas/policy-pack-v2.schema.json

13.9 · CLI Interface

# Export to Rego
stella policy export --file policy.json --format rego --output-file release.rego

# Import with validation
stella policy import --file external.rego --validate-only

# Validate policy document
stella policy validate --file policy.json --strict

# Evaluate with remediation hints
stella policy evaluate --policy baseline.json --input evidence.json --environment production

Exit codes: 0 = success/allow, 1 = warn, 2 = block/errors, 10 = input-error, 12 = policy-error.

13.10 · Policy Engine API

Group: /api/v1/policy/interop with tag PolicyInterop

MethodPathAuth PolicyDescription
POST/exportpolicy:readExport policy to format
POST/importpolicy:writeImport policy from format
POST/validatepolicy:readValidate policy document
POST/evaluatepolicy:readEvaluate policy against input
GET/formatspolicy:readList supported formats

13.11 · OPA Supply Chain Evidence Input

Sprint: SPRINT_0129_001_Policy_supply_chain_evidence_input

OPA policies can optionally access comprehensive supply chain evidence beyond basic VEX merge results. When PolicyGateContext.SupplyChainEvidence is populated, the following fields become available in the OPA input:

Input FieldTypeDescription
artifact.digeststringArtifact digest (e.g., sha256:abc...)
artifact.mediaTypestringOCI media type
artifact.referencestringFull artifact reference
sbom.digeststringSBOM content hash
sbom.formatstringFormat identifier (e.g., cyclonedx-1.7, spdx-3.0.1)
sbom.componentCountintNumber of components
sbom.contentobjectOptional inline SBOM JSON
attestations[]arrayAttestation references
attestations[].digeststringDSSE envelope digest
attestations[].predicateTypestringin-toto predicate type URI
attestations[].signatureVerifiedboolSignature verification status
attestations[].rekorLogIndexlongTransparency log index
transparency.rekor[]arrayRekor receipts
transparency.rekor[].logIdstringLog identifier
transparency.rekor[].uuidstringEntry UUID
transparency.rekor[].logIndexlongLog position
transparency.rekor[].integratedTimelongUnix timestamp
transparency.rekor[].verifiedboolReceipt verification status
vex.mergeDecisionobjectVEX merge decision
vex.mergeDecision.algorithmstringMerge algorithm (e.g., trust-weighted-lattice-v1)
vex.mergeDecision.inputs[]arraySource documents with trust weights
vex.mergeDecision.decisions[]arrayPer-vulnerability decisions with provenance

Code locations:

Example Rego policy using evidence:

package stella.supply_chain

default allow = false

# Require SBOM presence
allow {
    input.sbom.digest != ""
    input.sbom.componentCount > 0
}

# Require verified attestation with SLSA provenance
allow {
    some att in input.attestations
    att.predicateType == "https://slsa.dev/provenance/v1"
    att.signatureVerified == true
}

# Require transparency log entry within 24 hours
allow {
    some receipt in input.transparency.rekor
    receipt.verified == true
    time.now_ns() - (receipt.integratedTime * 1000000000) < 86400000000000
}

Last updated: 2026-02-09 (Sprint 049 — Proof Studio UX).

14 · Proof Studio (Explainable Confidence Scoring)

The Proof Studio UX provides visual, auditable evidence chains for every verdict decision. It bridges existing verdict rationale, score explanation, and counterfactual simulation data into composable views.

14.1 · Library Layout

StellaOps.Policy.Explainability/
├── VerdictRationale.cs              # 4-line structured rationale model
├── VerdictRationaleRenderer.cs      # Content-addressed render (text/md/JSON)
├── IVerdictRationaleRenderer.cs     # Renderer interface
├── ProofGraphModels.cs              # Proof graph DAG types
├── ProofGraphBuilder.cs             # Deterministic graph builder
├── ScoreBreakdownDashboard.cs       # Score breakdown dashboard model
├── ProofStudioService.cs            # Composition + counterfactual integration
├── ServiceCollectionExtensions.cs   # DI registration
└── GlobalUsings.cs

14.2 · Proof Graph

A proof graph is a directed acyclic graph (DAG) that visualizes the complete evidence chain from source artifacts to a final verdict decision.

Node TypeDepthPurpose
Verdict0Root: the final verdict + composite score
PolicyRule1Policy clause that triggered the decision
Guardrail1Score guardrail (cap/floor) that modified the score
ScoreComputation2Per-factor score contribution
ReachabilityAnalysis3Reachability evidence leaf
VexStatement3VEX attestation leaf
Provenance3Provenance attestation leaf
SbomEvidence3SBOM evidence leaf
RuntimeSignal3Runtime detection signal leaf
AdvisoryData3Advisory data leaf
Counterfactual0What-if hypothesis (overlay)

Edge relations: ProvidesEvidence, ContributesScore, Gates, Attests, Overrides, GuardrailApplied.

Graph IDs are content-addressed (pg:sha256:...) from deterministic sorting of node and edge identifiers.

14.3 · Score Breakdown Dashboard

The ScoreBreakdownDashboard exposes per-factor contributions with weighted contributions and percentages:

ScoreBreakdownDashboard
├── CompositeScore (int)
├── ActionBucket (string)
├── Factors[] → FactorContribution
│   ├── FactorId / FactorName
│   ├── RawScore, Weight → WeightedContribution (computed)
│   ├── Confidence, IsSubtractive
│   └── PercentageOfTotal
├── GuardrailsApplied[] → GuardrailApplication
│   ├── ScoreBefore → ScoreAfter
│   └── Reason, Conditions
├── PreGuardrailScore
├── Entropy
└── NeedsReview

14.4 · Counterfactual Explorer

The AddCounterfactualOverlay() method on IProofGraphBuilder adds hypothetical nodes to an existing proof graph. A CounterfactualScenario specifies factor overrides (factorId → hypothetical score) and an optional resulting composite score. The overlay:

  1. Creates a Counterfactual node at depth 0 with the scenario label.
  2. Connects overridden factor score nodes to the counterfactual node via Overrides edges.
  3. Recomputes the content-addressed graph ID, making each scenario distinctly identifiable.

14.5 · Proof Studio Service (Integration)

The IProofStudioService is the primary integration surface:

MethodInputOutput
Compose(request)ProofStudioRequest (rationale + optional score factors + guardrails)ProofStudioView (proof graph + optional score breakdown)
ApplyCounterfactual(view, scenario)Existing view + CounterfactualScenarioUpdated view with overlay

The service bridges ScoreFactorInput (from scoring engine) to FactorContribution models and formats factor names for UI display.

14.6 · DI Registration

services.AddVerdictExplainability();
// Registers:
//   IVerdictRationaleRenderer → VerdictRationaleRenderer
//   IProofGraphBuilder → ProofGraphBuilder
//   IProofStudioService → ProofStudioService

14.7 · OTel Metrics

MetricTypeDescription
stellaops.proofstudio.views_composed_totalCounterProof studio views composed
stellaops.proofstudio.counterfactuals_applied_totalCounterCounterfactual scenarios applied

14.8 · Tests

15 · Advisory Gap Status (2026-03-04 Batch)

Status: implementation delivered in Sprint 306.

Legacy fixture note:

Closure sprint: