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:tenantclaim viaIStellaOpsTenantAccessor. The legacyX-Tenant-Idheader is stripped at ingress and is never honoured as a tenancy source. Literal"default"tenant fallbacks have been removed fromAirGap/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. SeeSPRINT_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.
Exception counts and filtered reads (2026-09-09). GET /api/policy/exceptions/counts and the ordinary exception list require policy:read and the authenticated request tenant. They preserve that tenant as text, including non-UUID identifiers, in both the database session and an explicit tenant_id predicate. Missing tenant identity is refused; a forged raw header cannot select another tenant for an ordinary principal. Counts are tenant-wide lifecycle totals, independent of the list page size. active counts rows whose lifecycle status is active; expiringSoon counts active rows due within seven days, including overdue active rows.
The read contract uses a required text tenant for GetCountsAsync and a text tenant on ExceptionFilter. This correction does not migrate the separate ExceptionScope write model: its nullable UUID tenant member still cannot represent a non-UUID identity. Reading an existing text-tenant row therefore leaves that member null rather than inventing a UUID. Counts and read isolation do not depend on that member; broader exception CRUD tenancy remains separate work.
1 · Responsibilities & Constraints
- Compile and evaluate
stella-dsl@1policy packs into deterministic verdicts. - Join SBOM inventory, Concelier advisories, and Excititor VEX evidence via canonical linksets and equivalence tables.
- Evaluate SBOM license expressions against policy (SPDX AND/OR/WITH/+), emitting compliance findings and attribution requirements for gate decisions.
- Materialise effective findings into Policy-owned PostgreSQL tables (e.g.
policy.evaluation_runs+policy.explanations; see §4) with append-only history and produce explain traces. - Emit CVSS v4.0 receipts with canonical hashing and policy replay/backfill rules; store tenant-scoped receipts with RBAC; export receipts deterministically (UTC/fonts/order) and flag v3.1→v4.0 conversions (see Sprint 0190 CVSS-GAPS-190-014 /
docs/modules/policy/cvss-v4.md). - Emit per-finding OpenVEX decisions anchored to reachability evidence, forward them to Signer/Attestor for DSSE/Rekor, and publish the resulting artifacts for bench/verification consumers.
- Consume reachability lattice decisions (
ReachDecision,docs/modules/reach-graph/guides/lattice.md) to drive confidence-based VEX gates (not_affected / under_investigation / affected) and record the policy hash used for each decision. - Own the Assurance control-register schema, lifecycle stream, and read APIs: NIS2 is the first pack adapter, not a product-wide regional assumption. Policy documents may carry
complianceMappingsentries for the thirteen NIS2 thematic areas, Policy persistence stores control entries, replay events, and content-addressed snapshots for SoA export pinning,GET /api/policy/control-register/nis2preserves the NIS2 compatibility shape, andGET /api/policy/control-register/frameworks/nis2exposes the genericassurance-control-register-v1projection without downstream persistence coupling (see NIS2 control register). - Honor hybrid reachability attestations: graph-level DSSE is required input; when edge-bundle DSSEs exist, prefer their per-edge provenance for quarantine, dispute, and high-risk decisions. Quarantined edges (revoked in bundles or listed in Unknowns registry) must be excluded before VEX emission. See
docs/modules/reach-graph/guides/hybrid-attestation.mdfor verification runbooks and offline replay steps. - Require shadow + coverage evidence for new/changed policies as a governance prerequisite. The current Engine does not register the Registry shadow/review/promotion workflow or enforce its attachment checks; missing required evidence must hold promotion under the approved procedure, not become a fabricated pass (see the lifecycle guide).
- Operate incrementally: react to change streams (advisory/vex/SBOM deltas) with ≤ 5 min SLA.
- Keep simulation/diff workflows distinct from explicit revision evaluation; the Registry-backed simulation compatibility routes are unavailable as described in §1.2.
- Enforce strict determinism guard (no wall-clock, RNG, network beyond allow-listed services) and RBAC + tenancy via Authority scopes.
- Enforce fail-closed gate integrity: missing release baselines deny by default, signing secrets must be explicitly configured, canonical verdict hashes cover decision content rather than wall-clock inputs, transparency mode is explicit for offline or Rekor-backed deployments, and evidence paths never treat unsigned, base64-only, or placeholder local signatures as trusted production evidence.
- Support sealed/air-gapped deployments with offline bundles and sealed-mode hints.
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)
- Policy Gateway now initializes StellaOps localization with
AddStellaOpsLocalization(...),AddTranslationBundle(...),AddRemoteTranslationBundles(),UseStellaOpsLocalization(), andLoadTranslationsAsync(). - Locale resolution order for request-facing strings is deterministic:
X-Localeheader ->Accept-Languageheader -> default locale (en-US). - Translation sources are layered deterministically: shared embedded
commonbundle -> Policy embedded bundle (Translations/*.policy.json) -> Platform runtime override bundle. - The rollout localizes selected request validation and readiness responses for
en-USandde-DE.
1.2 · Simulation compatibility contract (Sprint 20260309_011)
- The legacy compatibility surface under
/policyno longer fabricates tenant-scoped simulation state in live hosts. StellaOps.Policy.Gatewayand the mergedStellaOps.Policy.Enginegateway surface now return501 problem+jsonfor the/policysimulation compatibility routes until a durable Policy Registry backend is wired.- This explicitly covers the previous shadow-mode, simulation-history, simulation-compare, exception-compatibility, merge-preview, and batch-evaluation compatibility routes that had been backed by process-local dictionaries.
StellaOps.Policy.Registrystill contains in-memory store implementations for its internal test harness, but the public runtime DI helper that exposed those registrations was removed. Registry in-memory storage is now testing-only and is no longer advertised as a live host composition path.
1.3 · Durable pack lifecycle versus Registry governance
Verified against source 00f64ca4d165fc54e7c291795b65326869b7627d (2026-08-31): Engine Program.cs registers IPolicyPackRepository as PostgresPolicyPackRepository and calls MapPolicyPacks(). The registered operational sequence is explicit Draft metadata, bundle compilation/storage, explicit Approved metadata upsert, then activation. These operations use /api/policy/packs, policy:edit for metadata/bundles, policy:read for listing/evaluation, and policy:activate for activation. Revision states are only Draft, Approved and Active.
The pack group’s tenant endpoint filter bridges the validated shared Router identity into Policy’s request-local accessor and clears it after dispatch. Native HTTP uses the same identity contract through middleware. Repository operations require that context before opening a connection and explicitly filter pack queries by its tenant; no implicit public selection is permitted. Verify this boundary with PolicyPackRouterTenantContextTests, including the database-owner/RLS-bypass case and the registered :activate route.
Batch evaluation applies the same transport bridge and rejects a body tenant that differs from validated identity before invoking its evaluator. Both transports normalize only the exact operator project wildcard * to no project refinement. Pack evaluation captures the validated tenant once and uses a typed cache identity containing tenant, pack, version, bundle digest, subject and findings digest. Correlation IDs hash the canonical JSON of that identity; identical policy content cannot reuse another tenant’s attestation reference or another revision’s response. This changes future correlation IDs, not historical stored receipts.
Approved is metadata set by the edit-scoped endpoint, not a reviewer decision. Compilation does not approve a Draft. The two-person requirement is resolved from PolicyEngine:Activation:ForceTwoPersonApproval, the request and DefaultRequiresTwoPersonApproval when metadata is first created; existing revision upserts do not update it. Activation checks Approved state and distinct actor IDs when required, returning 202 while a second approval is pending. It does not enforce author/reviewer separation, shadow/coverage evidence or bundle presence, and does not enqueue a run or promote an environment. Metadata and bundle fields are updateable; preservation of the reviewed revision/digest is an operational obligation, not storage-enforced content immutability.
Registry submission/review/publication services are not registered by the Engine host. GET /policy/capabilities reports simulation and shadow availability (both source defaults false); the compatibility handlers remain 501. Required reviewer, shadow, determinism and publication evidence is not waived: hold the governed operation when the approved procedure cannot supply it. PolicyBundleService.Sign is a deterministic offline-testing signature stub, not a DSSE or operator-approval attestation; this is separate from post-evaluation verdict attestation.
Re-verify with rg -n 'PostgresPolicyPackRepository|MapPolicyPacks|MapPolicyCapabilitiesEndpoints|MapPolicySimulationEndpoints' src/Policy/StellaOps.Policy.Engine/Program.cs, then inspect the enclosing endpoint/repository methods and the lifecycle source checks. This source boundary does not claim any current deployment or policy has completed governance.
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:
- API host exposes durable pack/revision, run, findings, gate, governance and exception endpoints with OAuth bearer + scope enforcement; Registry simulation/review compatibility availability is bounded by §1.2–§1.3 (resource-server validation; DPoP, where required, is enforced upstream at Authority/gateway).
- Router-forwarded requests are authenticated from the gateway-signed identity envelope in both Policy executable hosts.
UseIdentityEnvelopeAuthentication()runs before bearer authentication, authorization, and tenant middleware, verifies the configured signing key and envelope validity window, and hydrates the subject, tenant, project, roles, andscopeclaims. A missing, invalid, expired, or unverifiable envelope remains unauthenticated, so protected routes fail closed with 401/403. Hydration does not grant authorization: each endpoint still requires its exact Policy scope (for example,policy:readfor the shadow-config read path), while direct callers may continue through normal bearer authentication. - The merged Policy Engine host maps both budget endpoint families before
TryRefreshStellaRouterEndpoints: unknown-budget configuration/status under/api/v1/policy/budgetsand release risk-budget ledger/enforcement under/api/v1/policy/budget. Both route groups applyRequireTenantContext()to every route. This endpoint filter is load-bearing for Valkey/Router dispatch, which invokes the matched ASP.NET endpoint without running Policy’s normal tenant middleware: it copies the already verified shared Router tenant identity into Policy’s request-local tenant accessor and clears it after the handler returns. Tenant-sensitive unknown-budget reads then resolve that identity throughIStellaOpsTenantResolver: canonical UUIDs pass through, while active named tenants such asdefaultresolve throughshared.tenantsto the directory UUID used by both the repository call and PostgreSQL RLS session. Missing context fails at the filter, while inactive or unknown identifiers fail closed before repository construction; there is no public/default fallback. Reads requirepolicy:read, while mutations retain their endpoint-specific operate/edit scopes. The host composes the existing scopedUnknownsRepository,PostgresBudgetStore,BudgetLedger, andBudgetConstraintEnforceroverPolicyDataSource;AddPolicyPostgresStorageremains the startup-migration authority for the shared Policy schema. Router advertisement is derived from the hostEndpointDataSource, so a defined endpoint extension that is not called fromProgram.csis not a live or routable contract. - Orchestrator manages run scheduling/fairness; writes run tickets to queue, leases jobs to worker pool.
- Workers evaluate policies using cached IR; join external services via tenant-scoped clients; pull immutable advisories/VEX from the raw stores; write derived overlays to PostgreSQL and optional explain bundles to blob storage.
- Observability (metrics/traces/logs) integrated via OpenTelemetry (not shown).
- Canonical policy pack runtime state is durable: pack containers, revision activation state, activation-actor records, bundle digests/payloads, compilation signature values, AOC metadata and persisted DSL source live in PostgreSQL (
policy.packs,policy.pack_versions). Runtime reload reconstructs compiled documents from persisted source. This is not Registry review history or trusted DSSE publication; §1.3 describes the signature and governance limits. - Canonical violation runtime state is also durable: emitted violation events, fused severities, and persisted conflict records now live in PostgreSQL (
policy.violation_events,policy.violation_fusion_results,policy.conflicts). Restarted hosts reuse persisted event/fusion/conflict state through the/policy/violations/*API instead of rebuilding from an in-memory store. - Additional canonical Policy runtime state is now durable as well: verification policies, attestation reports, risk scoring jobs, console export jobs/executions/bundles, policy-pack bundle imports, and sealed mode state persist in PostgreSQL (
policy.verification_policies,policy.attestation_reports,policy.risk_scoring_jobs,policy.console_export_*,policy.policy_pack_bundle_imports,policy.sealed_mode_states) and survive host restart without in-memory recovery logic. - Human governance decisions are durable and can carry a verified operator signature (ADR-025). The exception/waiver approve (
policy.exception_approval_requests, M-of-N), the gate-bypass decision (policy.gate_bypass_audit, viaIGateBypassAuditor.RecordBypassAsync— not the 501PolicySimulationEndpointsstub), and the risk-override approve (policy.risk_overrides) each persist an optionaloperator-decision@v1DSSE envelope in nullable signature columns (operator_decision_envelope/_keyid/_digest+operator_signature_verified), verified throughIOperatorDecisionVerifier/ICryptoProviderRegistry(never bare BCL) and bound to the approver’s verified tokensub. Enforcement of a required signature is gated per-decision-type behind a default-off options flag (Policy:ExceptionApprovalSigning:RequireOperatorSignature,Policy:GateBypassAudit:RequireOperatorSignature,Policy:RiskOverrideSigning:RequireOperatorSignature), so existing flows are byte-for-byte unchanged until a signing client lands. The risk-override decision record was previously in-memory only; it is now durable inpolicy.risk_overrides(backingOverrideServicethrough theIRiskOverrideStore/PostgresRiskOverrideStorewrite-through seam), so an approve and its operator signature survive host restart, and the in-memory read model is rehydrated from the store on startup (RiskOverrideHydrationService). The effective policy binding followed the same correction inSPRINT_20260905_002(PLC-3):EffectivePolicyServiceheld bindings, and their authority scope attachments, inConcurrentDictionaryfields on a singleton with no table behind them, so twopolicy-enginerecreates on 2026-09-07 emptied the estate’s effective policies whileGET /api/v1/authority/effective-policieskept answering200with an empty list. They are now durable inpolicy.effective_policy_bindings/policy.effective_policy_scope_attachmentsbehind theIEffectivePolicyStore/EffectivePolicyStoreseam, with the dictionaries deleted rather than kept in front of the store (§2.11: no dual-write). A parallel re-point applies to the VEX override decision in Findings (RealVexOverrideAttestorAdapter, SPRINT_20260608_015 POLICY-OPSIGN-005): when the operator supplies anoperator-decision@v1envelope, the produced VEX-override attestation is anchored to the operator’s enrolled key (verified throughIOperatorDecisionVerifier/ICryptoProviderRegistry, non-custodial) instead of the service signing key. - OSK-P1 supersedes the preceding global enforcement description for production composition. Required signing is resolved once per tenant from the default-empty
Policy:OperatorSigningRequirement:Tenantsmap; exception approval, gate bypass, and risk override all consume that same decision. A tenant is required only when its exact id/slug maps totrue; absent/false entries remain OFF, and the legacy per-decisionRequireOperatorSignaturebooleans cannot globally enable a production tenant. - Operator decision-signing key resolution is cache-first with Authority read-through.
PolicyTrustedKeyOperatorDecisionResolverfirst resolves the envelopekeyidfrompolicy.trusted_keys; on a miss, and only whenIssuerDirectory:Client:BaseAddress,Policy:ExceptionApprovalSigning:IssuerId, and a request tenant are configured, Policy pulls the public operator key from IssuerDirectory and upserts the read-sidepolicy.trusted_keysrow. Browser raw ES256DssePublicKeymaterial is normalized to SPKI before caching. Missing client/issuer/tenant or IssuerDirectory failure is fail-closed. - OSK-P5 bounds cached lifecycle state as well as key material. After
PostgresTrustedKeyRegistryOptions.CacheTtlSeconds(default 300 seconds), the operator-decision resolver refreshes an existing verification-view row from IssuerDirectory, persists provider-changeRetiredstatus +retiredAtcutoff, and invalidates the prior in-memory verify-only entry. A due refresh that cannot run or fails does not fall back to stale Active state. Historical attestation verification still evaluates the key against the signed decision timestamp, but each exception approval, risk override, and gate-bypass grant then applies a separate exact-key authorization at current server time. The old key can therefore verify append-only history while being hard-blocked from creating a new grant at or after its grace cutoff. - Promote-time CVE gates keep Scanner runtime status separate from Scanner evidence without opening Scanner’s database.
PostgresFindingsLookupcalls Scanner’s bounded tenant-scopedGET /api/v1/scans/by-digest/{imageDigest}/detailAPI throughIScannerDigestDetailClientusing an exact-tenant Authority token with ordinaryscanner:read.latestAttemptsurfaces a freshpending/runningscan asreachability.state=computing, while independently anchoredlatestSuccessfulEvidencepreserves the last successful component PURLs and reachability facts. A pending/running attempt older than six hours degrades tounknown, so a dead worker cannot leave findings permanently computing or hide the last successful evidence. The client bounds response bytes and concurrency, requires exact tenant/digest/status wire values, and verifies both producer-owned set hashes. Authentication, transport, malformed/oversized response, or unavailable-owner failures enter the configured fail-closed lookup path;404alone means no Scanner attempt exists. Missing values,isReachable:false, and barenot-observedtokens remainunknownbecause this seam carries no signed coverage object. - Release-time policy evaluation caches by policy bundle digest, opaque subject, and a deterministic digest of the current resolved findings. The findings lookup runs before cache selection for CVE-aware packs, so advisory, match-state, severity, package, or reachability changes invalidate an earlier allow/deny response while an unchanged findings projection still reuses the cached decision and attestation.
- Assurance control-register state is durable in PostgreSQL (
policy.control_register_entries,policy.control_register_events,policy.control_register_snapshots) through the current NIS2 storage adapter. The event stream is tenant-scoped and replay ordered, and event details includeframeworkId/packIdmetadata. Runtime readers that need the legacy NIS2 shape useGET /api/policy/control-register/nis2, which returns exactly thirteen area summaries, owner/review/evidence/tenant-override refs, audit event ids, and deterministic cursor pages. Runtime readers that need the module-neutral contract useGET /api/policy/control-register/frameworks/nis2, which returnsassurance-control-register-v1with genericareaOrDomain,externalRefs, and evidence source labels. - CRA technical-documentation provenance is exposed through a Policy-owned projection, not by deriving values from generic Policy ledger or console exports. ExportCenter remains the owner of
cra-tech-file-v1bundle production and EvidenceLocker remains the owner of the EU regulatory artifact ledger. When the CRA export workflow supplies Policy with a provenance snapshot, Policy stores it inpolicy.cra_technical_documentation_provenanceand serves the latest tenant snapshot fromGET /api/policy/compliance/cra/technical-documentation/provenance/{tenantId}. The response contract is{ tenantId, exportId?, generatedAtUtc?, status, fields[], unprovenancedFieldCount }, wherestatusisok,unprovenanced, ormissing; field entries are sorted bypathand preserve nullsourceRef,signerRef, andsignedAtvalues.POST /api/policy/compliance/cra/technical-documentation/provenance/{tenantId}is the Policy ingestion boundary for the snapshot and uses source contractpolicy-cra-technical-documentation-provenance-v1:exportIdandgeneratedAtUtcare supplied by the CRA export run, while each field-levelsourceRef,signerRef, andsignedAtis supplied from the CRA technical-file signing/evidence provenance. Requests fail closed when the route tenant id does not match the envelope-boundstellaops:tenantclaim. - The standalone Policy Gateway no longer uses a process-local outbound token cache. Client-credentials token reuse now flows through
MessagingTokenCachebacked by PostgreSQL transport tables on the Policy DSN, matching the engine’s durable messaging-cache pattern. - The merged gateway governance surface is no longer backed by process-local dictionaries.
/api/v1/governance/sealed-mode/statusand/api/v1/governance/sealed-mode/toggleresolve through the persistedISealedModeService,/api/v1/governance/risk-profiles*now exposes only configuration-backed read-only projections plus stateless validation, and unsupported governance write/trust-weight/staleness/conflict/audit compatibility routes return truthful501responses instead of fabricated state. - The standalone Policy Gateway also no longer fabricates governance compatibility state. Its
/api/v1/governance/*compatibility routes now return truthful501responses unless a real governance backend is explicitly wired behind the host.
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.
- No standalone operator-facing key change. The standalone composition’s lazy config bridge (
AddPrefixAlias) maps the existingPostgres:Policy:*operator keys onto themessaging:postgres:*keys the Postgres transport plugin binds (ConnectionString,Schema,AutoCreateTables,CommandTimeoutSeconds), and defaultsmessaging:transport=postgres. The alias is resolved at bind time so config layering order is preserved (the plugin’s deferred.Bind()/ValidateOnStart()reads the final values, and a test host’s in-memoryPostgres:Policy:*overrides are still honoured). The plugin’s own[Required] ConnectionStringvalidation fails fast at startup when the DSN is genuinely missing. - Mounted-bundle discovery. The standalone composition resolves the signed bundle directory
<Messaging:BundleRoot>/<Messaging:Profile>(defaults/app/plugins/messaging/recommended) and trust root/app/etc/certificates/trust-roots/plugins/messaging/cosign.pub(overridable viaMessaging:PluginDirectory/Messaging:TrustRootPath/Messaging:EnforceSignatureVerification). Image-resident transports loaded into the Default ALC are not re-gated (signed at build time); the directory load path stays fail-closed (see §2.0b). - Composition coverage.
PolicyMessagingCompositionTestsproves the canonical Router-enabled Valkey registration is the single effective factory, proves the Router-disabled Postgres fallback, and rejects zero/duplicate factory ownership. The standalone API fixture still usesmessaging:transport=postgresplus a Default-ALCModuleInitializerto make fallback discovery deterministic.
Current canonical Compose is Router-enabled and uses the admitted signed
base/ Valkey bundle. A signedrecommended/ 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:
- manifest binding — manifest
id == bundle directory name, modulemessaging, contract versionmessaging.backend.v1, and the per-transport required capabilitymessaging:backend:<transport>(e.g.messaging:backend:postgres, derived from the bundle directory name); - per-assembly SHA-256 vs the manifest digest, plus a path-traversal guard;
- detached RSA-PKCS1-SHA256 verification of
<assembly>.sigviaOfflineDevRsaSha256PluginVerifierwithAllowUnsigned = falseagainst the configured trust root.
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
- Raw-only reads. Evaluation workers access
advisory_raw/vex_rawvia tenant-scoped PostgreSQL clients or the Concelier/Excititor raw APIs. No Policy Engine component is permitted to mutate these tables. - Guarded ingestion.
AOCWriteGuardrejects forbidden fields before data reaches the raw stores. Policy tests replay knownERR_AOC_00xviolations to confirm ingestion compliance. - Change streams as contract. Run orchestration stores resumable cursors for raw change streams. Replays of these cursors (e.g., after failover) must yield identical materialisation outcomes.
- Derived stores only. All severity, consensus, and suppression state lives in Policy-owned PostgreSQL tables (
policy.evaluation_runs,policy.explanations,policy.risk_scores,policy.violation_events, etc.; see §4) and explain bundles owned by Policy Engine. Provenance fields link back to raw document IDs so auditors can trace every verdict. - Authority scopes. Only the Policy Engine service identity holds
effective:write(StellaOpsScopes.EffectiveWrite, enforced on/api/v1/authority/effective-policies). Read access to materialised findings usesfindings:read(StellaOpsScopes.FindingsRead); there is noeffective:readscope. Ingestion identities retainadvisory:*/vex:*scopes, ensuring separation of duties enforced by Authority and the API Gateway.
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 undersrc/Policy/;StellaOps.Policy.Determinization/lives undersrc/Policy/__Libraries/) or a shared library (theStellaOps.Policygates library is also undersrc/Policy/__Libraries/). The merged engine host (Program.cs) wires all of these plus the former standalone gateway endpoints underEndpoints/Gateway/.
| Module | Responsibility | Notes |
|---|---|---|
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), persist artifacts for Export Center / bench repo. | Registered via AddVexDecisionEmitter() (POLICY-VEX-401-006) — which registers exactly IPolicyGateEvaluator and IVexDecisionEmitter; integrates with Signer predicate stella.ops/vexDecision@v1 and Attestor Rekor logging. Proof-spine handling is NOT part of this component (corrected 2026-08-18): VexProofSpineService was listed here as if registered, and it never was — no host registered it and no caller invoked it. It was deleted under SPRINT_20260722_007 POL-F3 because it was the only reason policy-engine compiled StellaOps.Scanner.ProofSpine, and through it the whole Attestor family. A revived VEX proof-spine feature rides a Scanner-owned seam, not a compile edge. |
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
}
}
| Option | Type | Default | Description |
|---|---|---|---|
SignalWeights | Object | See above | Relative 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). |
PriorDistribution | Enum | Conservative | Prior distribution for missing signals. Options: Conservative (pessimistic), Neutral, Optimistic. Affects uncertainty tier classification when signals are unavailable. |
ConfidenceHalfLifeDays | Double | 14.0 | Half-life period for confidence decay in days. Confidence decays exponentially: exp(-ln(2) * age_days / half_life_days). |
ConfidenceFloor | Double | 0.1 | Minimum confidence value after decay (0.0-1.0). Prevents confidence from decaying to zero, maintaining baseline trust even for very old observations. |
ManualReviewEntropyThreshold | Double | 0.60 | Entropy threshold for triggering manual review (0.0-1.0). Findings with entropy ≥ this value require human intervention due to insufficient signal coverage. |
RefreshEntropyThreshold | Double | 0.40 | Entropy threshold for triggering signal refresh (0.0-1.0). Findings with entropy ≥ this value should attempt to gather more signals before verdict. |
StaleObservationDays | Double | 30.0 | Maximum age before an observation is considered stale (days). Used in conjunction with decay calculations and auto-refresh triggers. |
EnableDetailedLogging | Boolean | false | Enable verbose logging for entropy/decay/trust calculations. Useful for debugging but increases log volume significantly. |
EnableAutoRefresh | Boolean | true | Automatically trigger signal refresh when entropy exceeds RefreshEntropyThreshold. Requires integration with signal providers. |
MaxSignalQueryRetries | Integer | 3 | Maximum retry attempts for failed signal provider queries before marking signal as unavailable. |
Metrics emitted:
stellaops_determinization_uncertainty_entropy(histogram, unit: ratio): Uncertainty entropy score per CVE/PURL pair. Tags:cve,purl.stellaops_determinization_decay_multiplier(histogram, unit: ratio): Confidence decay multiplier based on observation age. Tags:half_life_days,age_days.
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:
- Calculate uncertainty entropy from signal snapshot
- Aggregate weighted signal scores via TrustScoreAggregator
- Compute K4 lattice verdict (Unknown/True/False/Conflict)
- Extract dimension scores (BaseSeverity, Reachability, Evidence, Provenance)
- Compute weighted final score in basis points (0-10000)
- Determine risk tier (Info/Low/Medium/High/Critical)
- 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 |
|---|---|
| ≥ 9000 | Critical |
| ≥ 7000 | High |
| ≥ 4000 | Medium |
| ≥ 1000 | Low |
| < 1000 | Info |
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"
}
}
sbom.licenseexposes the compliance report (findings, conflicts, inventory).sbom.license_statusexposespass,warn, orfail(orunknownwhen disabled).- Failures set the policy verdict status to
blockedand emitlicense.*annotations. - Trademark notice obligations are tracked alongside attribution requirements and produce warn-level findings.
- License compliance reports support JSON, text/markdown/html, legal-review, and PDF outputs.
- Category breakdown includes percent totals and chart renderings (ASCII chart in text/markdown/legal-review/PDF, pie chart in HTML).
3.3 - NTIA compliance configuration
NTIA minimum-elements validation runs when enabled under ntiaCompliance.
{
"ntiaCompliance": {
"enabled": true,
"enforceGate": false,
"policyPath": "policies/ntia-policy.yaml"
}
}
sbom.ntiaexposes NTIA compliance details (elements, findings, supplier status).sbom.ntia_statusexposespass,warn,fail, orunknown.- NTIA compliance can be configured as an advisory-only check or a release gate via
enforceGate. - The NTIA policy supports element selection, supplier validation (placeholder patterns, trusted/blocked lists), and framework-specific requirements.
- Reports support JSON, text/markdown/html, and PDF output for regulatory submissions.
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,002–015) is provenance of DDL folded into the baseline, not live filenames — those files sit underMigrations/_archived/pre_1.0/(and.../mig061/) and are excluded from embedding. Note the leaf-name collision hazard:001_initial_schema.sqlexists in the archived tree only.
Consolidated baseline 001_v1_policy_baseline.sql (folds in the pre-1.0 001_initial_schema.sql):
policy.packs/policy.pack_versions/policy.rules— pack containers, revision metadata, and per-version rules (rule_type ∈ rego|json|yaml, severity, content hashes).pack_versionslater gains activation columns (requires_two_person_approval,activation_approvals_json,activated_at,bundle_digest,bundle_signature) via010. The current repository can update revision metadata and bundle fields; content preservation and reviewer evidence must not be inferred from the version number (§1.3).policy.risk_profiles/policy.risk_profile_history— versioned risk profiles (thresholds, scoring weights, exemptions) with append-only history.policy.evaluation_runs— run records (statuspending|running|completed|failed, resultpass|fail|warn|error, score, finding counts,input_hash, timings). This is the canonical “run” table — notpolicy_runs.policy.explanations— per-rule explain rows linked to a run (evaluation_run_id), with result/severity/message/remediation/resource path.policy.cvss_receipts— CVSS v3.1/v4.0 receipts (vector, base/threat/environmental/full scores, policy hash, canonical metrics, evidence/attestation refs, history, amend chain), unique by(tenant_id, input_hash).policy.epss_scores/policy.epss_history/policy.epss_thresholds,policy.risk_scores/policy.risk_score_history— EPSS cache and risk-score state.policy.snapshots,policy.violation_events,policy.conflicts,policy.ledger_exports,policy.worker_results,policy.unknowns,policy.recheck_policies,policy.evidence_hooks.- Exception subsystem:
policy.exceptions,policy.submitted_evidence,policy.exception_events,policy.exception_applications,policy.budget_ledger,policy.budget_entries,policy.exception_approval_requests,policy.exception_approval_audit,policy.exception_approval_rules. policy.audit— module audit rows.
(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):
002—policy.trusted_keys,policy.gate_bypass_audit.003—policy.gate_decisions;004—policy.replay_audit.005—policy.advisory_source_impacts,policy.advisory_source_conflicts;006— audit VEX columns.007—policy.engine_ledger_exports,policy.engine_snapshots(runtime snapshot/ledger persistence);008— snapshot artifact-identity columns;009—policy.orchestrator_jobs.011—policy.violation_fusion_results.012—policy.verification_policies,policy.attestation_reports,policy.risk_scoring_jobs,policy.console_export_jobs,policy.console_export_executions,policy.console_export_bundles,policy.policy_pack_bundle_imports,policy.sealed_mode_states.013— drops deprecated audit tables (the former013_exception_approval.sqlwas superseded; its tables live in the baseline at lines 730/770/789).014—policy.control_register_entries,policy.control_register_events,policy.control_register_snapshots(Assurance / NIS2 register).015—policy.cra_technical_documentation_provenance.
Post-baseline forward-only migrations — these are the live 002–007 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). 002–004 add the operator-signed governance-decision support (ADR-025, sprint SPRINT_20260608_015); 005–007 are later additions listed after them:
002_operator_decision_signing_columns.sql— adds the operator-decision signature columns (operator_decision_envelope/_keyid/_digestTEXT +operator_signature_verifiedBOOLEAN NOT NULL DEFAULT FALSE) topolicy.exception_approval_requestsandpolicy.gate_bypass_audit, mirroring thepolicy.submitted_evidence.dsse_envelope/signature_verifiedprecedent.003_operator_decision_signing_view.sql—policy.trusted_keysoperator decision-signing verification-view delta (subject_id,provider_hint,key_purpose,retired_at,status,replaces_key_id+ partial indexes) so an enrolled operator key resolves on the Policy verify path.004_risk_override_store.sql— createspolicy.risk_overrides(the durable risk-override decision record, replacing the former in-memory store: opaqueoverride_id,profile_id,override_type, predicate/action/tags as JSONB, priority, status, expiration, and flattened audit metadata; backsOverrideServicethroughIRiskOverrideStore, tenant is a string slug) with the same operator-decision signature columns + a partial signed index; RLS tenant isolation mirrorsgate_bypass_auditwithout the append-only immutability trigger (overrides are legitimately approved/disabled/deleted).005_vuln_gate_projection.sql— createspolicy.vuln_gate_currentandpolicy.vuln_tenant_overlay, the Policy-owned tenant gate projection fed by hub consensus events (ADR-039 D3,SPRINT_20260722_007POL-F1). It is the successor of Concelier’svuln.issue_gate_decisions, which forced Policy to read its own decisions across a database boundary.006_exploit_evidence_projection.sql— createspolicy.exploit_evidence,policy.exploit_epssand the single-rowpolicy.exploit_evidence_sync, the Policy-local mirror of the hub corpus exploit-evidence section that producesis_kevfor the drift gate (P15 forbids a gate-time hub call). The sync-state row is load-bearing: the lookup answers NULL until a sync completes, so a fresh estate stays honest-indeterminate instead of reading an empty table as “measured, no KEV”.007_effective_policy_bindings.sql— createspolicy.effective_policy_bindingsandpolicy.effective_policy_scope_attachments(SPRINT_20260905_002PLC-3), the durable home of the effective policy bindings described in §CONTRACT-AUTHORITY-EFFECTIVE-WRITE-008. Both carry the P13operationalretention class and RLS tenant isolation onpolicy_app.require_current_tenant(); attachments cascade from their binding. Before this migration the bindings were aConcurrentDictionaryon a singleton, so everypolicy-enginerecreate emptied them. The tables seed nothing (§2.7), so an empty list means nothing has been written, never that bindings were lost.
All of them 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
- Runtime durability (per §2): pack/version/activation state, violation events + fusion + conflicts, verification policies, attestation reports, risk-scoring jobs, console-export jobs/executions/bundles, pack-bundle imports, sealed-mode state, engine snapshots/ledger exports, orchestrator jobs / worker results, and effective policy bindings with their authority scope attachments all persist and survive host restart without in-memory recovery.
policy.gate_decisionsis the durable release-gate decision history. SynchronousPOST /api/v1/policy/gate/evaluateand scheduler-backed async gate evaluations both record a row after the response is built. Rows carry the canonical tenant UUID, evaluated image digest, BOM/reference string, gate status (Pass|Warn|Fail), verdict hash, optional policy bundle id/hash, CI context, actor, and evaluated timestamp. The table is also the short-term cross-domain read projection consumed by the Integrations registry image browser to show per-image Policy verdict state by digest.evaluation_runs.input_hashand the engine snapshot/ledger digests support replay verification; the signed messaging backend provides bounded reachability-facts overlay caching (Router-selected Valkey in canonical Compose, standalone Postgres otherwise).- Tables are tenant-scoped via
tenant_id(TEXT slug or UUID depending on table) alongsidecreated_at/updated_ataudit columns. - Pack/version reads explicitly restrict
policy.packs.tenant_idbefore joining versions by their pack ID. Tenant session variables andrelrowsecurity=trueare not sufficient proof: an owner role can bypass RLS. Missing validated pack context is rejected before connection use.
4.3 Indexing
- Representative indexes:
idx_packs_tenant,UNIQUE(tenant_id, name)onpacks;UNIQUE(pack_id, version)onpack_versions;idx_evaluation_runs_tenant/_project/_artifact/_statusonevaluation_runs;idx_explanations_runonexplanations;idx_cvss_receipts_tenant_vulnandUNIQUE(tenant_id, input_hash)oncvss_receipts; GIN index onrules.tags. - EPSS rows carry
expires_at(defaultNOW() + 7 days) for cache freshness rather than database TTL eviction.
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 resolves a release subject (release:<id>|env:<name>|...) by calling ReleaseOrchestrator’s authenticated tenant-scoped component owner API, then Scanner’s bounded digest-detail owner API. It does not read release_orchestrator.* or scanner.* through PolicyDataSource. RO supplies the persisted digest, nullable package PURL and ecosystem; Policy preserves curated name/version semantics, including tag fallback for an explicitly null version. Empty releases, absent or malformed owner metadata, authentication failure and transport failure are unavailable inputs, not clean releases. These paths fail before opening Policy’s own database connection.
PolicyEngine:ReleaseComponents declares the direct owner origin and bounded request timeout, response bytes and component count. Each request uses an exact-tenant release:read token; redirects and cookies are disabled, and response URLs never select a request destination. The existing policy-engine service identity needs that read grant, not release mutation or publication scopes. POLICY_RELEASE_COMPONENTS_BASE_ADDRESS selects the Compose origin. The source contract is covered by ReleaseComponentsClientTests, ReleaseComponentsLookupIntegrationTests and RO’s ReleaseComponentsParityTests / ReleaseComponentsOpenApiTests; deployment and live consumer acceptance remain separate gates.
Scanner responses separate latest-attempt freshness from latest successful evidence. Policy validates the exact tenant/digest and ScanFactsDigest hashes before joining. Its advisory source is Policy’s own policy.vuln_gate_current projection, and only that: there is no operator mode key selecting a source.
Retirement note (2026-09-14,
SPRINT_20260914_001VRP-2, ownership-matrix X3). TheSPRINT_20260722_008G1 window dropped thevulnschema on 2026-08-04. ThePolicyEngine:FindingsLookup:AdvisorySourceselector (Legacy/GateDecision/PolicyProjection), thePolicyEngine:FindingsLookup:ShadowCompareparity pass and the two readers ofvuln.advisory_affectedandvuln.issue_gate_decisionsbehind them were deleted, not re-homed: the tables existed in none of the estate’s databases, Policy connects only tostellaops_policy, and selecting either retired source produced42P01and a deny. A host that still receivesAdvisorySource(other than the survivingPolicyProjectionvalue) or anyShadowComparerefuses to start; the failure names the key and the retiring row (FindingsAdvisorySourceOptionsValidator). The source-contract pinReleaseComponentsLookupIntegrationTests.SourceContract_HasOwnerClientAndNoForeignSqlrejects anyFROM/JOINagainstrelease_orchestrator.*orvuln.*in the lookup.
PolicyEngine:FindingsLookup:OnUnavailable decides what a gate does when the findings source cannot answer, as distinct from answering “nothing affects this release”. Both arrive as an empty CVE set, and conflating them is how a gate fails open — no CVEs resolved means no denylist rule matches, which reads as allow. Deny (the default, and the recorded steady-state posture for production-class environments) blocks with the reason on the evidence record; WarnAndAllow is the documented opt-DOWN for lower environments and still refuses to describe the verdict as clean. An unconfigured host fails closed. Two conditions raise the flag: any exception from the lookup, and a projection that has never been fed, witnessed by the absence of the projector’s durable consumer checkpoint in eventing.consumer_checkpoints (the checkpoint is the right witness and a row count is not: an empty table is legitimate for a clean tenant, whereas a missing checkpoint proves no hub event was ever applied). That readiness guard is unconditional.
The compose operator control is POLICY_FINDINGS_ON_UNAVAILABLE (default Deny); it binds through the service-prefixed configuration key and requires a Policy container recreate to change.
The projection that feeds PolicyProjectionis driven by VulnGateProjectionHost (src/Policy/__Libraries/StellaOps.Policy.Persistence/Postgres/VulnGate/VulnGateProjectionHost.cs), hosted in policy-engine by VulnGateProjectionWorker. It acquires the policy.vuln_gate.projector lease, drains the hub’s vuln.consensus stream over the owner API (never a connection to stellaops_vuln — CoC §8.2), and renews the lease between batches so the liveness requirement is one batch per TTL rather than the whole backlog per TTL. That heartbeat is not optional politeness: renewing only after an unbounded drain is G0 finding F10, where the hub’s equivalent host fenced itself roughly once per TTL with no competitor present and converged on 51 % of its stream. It is opt-in via PolicyEngine:VulnGateProjection:Enabled (default false) plus PolicyEngine:VulnGateProjection:Hub:BaseAddress (required, no default); when disabled the registration extension registers none of the projector’s dependencies, so a host that never opted in cannot fail to start over a hub address it does not need, and the disabled state is logged rather than left to be inferred from an empty table.
The projection query keys on the normalized package_name and ecosystem columns and passes effective_affected_range through the shared EcosystemVersionFilter. 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. The 2026-07-15 CANON-D6 parity, benchmark, and live forcing-function gates closed with zero unexplained recall regression when gate decisions first became authoritative; the projection inherited that row shape and status semantics unchanged.
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 shape | Advisory shape | Pre-036 result | Post-036 result |
|---|---|---|---|
Alpine openssl 3.1.4-r1 (apk) | Debian openssl (deb) | FALSE-POSITIVE match | no match |
Debian libssl1.1 (deb) | Alpine openssl (apk) | FALSE-POSITIVE match | no match |
npm @scope/pkg | npm short pkg | FALSE-POSITIVE match | no match |
Maven org.apache.commons:commons-lang3 | short commons-lang3 (maven) | FALSE-POSITIVE match | no match |
PyPi Django | Debian python3-django | FALSE-POSITIVE match | no match |
OCI base image redis | PyPi redis | FALSE-POSITIVE match | no match |
Contract after Sprint 20260531.036:
- Producer (
PostgresReleaseTruthRepository.UpsertComponentAsync) writes two new nullable columns to everyrelease_componentsrow:package_purl(canonical PURL string) +package_ecosystem(lowercased PURL type —apk,deb,npm,maven,pypi,oci, …). OCI image components default topackage_ecosystem = "oci"and a synthetic PURLpkg:oci/<image-ref>@<digest>. The owner API preserves explicit null metadata; components whose ecosystem remains unresolved retain the advisory planner’s name-only admission semantics. The former per-rowpolicy.findings_lookup.legacy_name_matchdebug message was removed with Policy’s direct release-table reader. The same event name remains as an aggregate message only insideLoadLegacyCvesForComponentsAsyncwhen that advisory branch reaches its name-only pass; neither an owner read nor thePolicyProjectionpath guarantees it. This describes retained source branches, not permission to reactivate the retired advisory database plane. - Release Orchestrator persistence adds the columns + partial indexes (
ix_release_components_purl/ix_release_components_ecosystem_name) in its consolidated baselinesrc/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Persistence/Migrations/001_v1_releaseorchestrator_baseline.sql:905(folded in from the archived014_release_components_purl_ecosystem.sql). Forward-only per ADR-004. Re-runnable. - 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)ANDlower(af.ecosystem) = ANY(@ecosystems).@ecosystemsis the FAN-OUT of every equivalent spelling perEcosystemEquivalence(so an advisory tagged"alpine"still matches a component typedapk, 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).
- Scoped pass — fires only when at least one component carries a canonical ecosystem; SQL has both
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. TheEquivalentSpellings()reverse lookup is what backs the SQLANY(@ecosystems)parameter.EcosystemAwareJoinPlan(src/Vulnerabilities/__Libraries/StellaOps.VulnMatch.Core/EcosystemAwareJoinPlan.cs) is the pure-function planner extracted fromPostgresFindingsLookupso the bucket-key + ecosystem fan-out semantics are unit-testable without a live PostgreSQL fixture. It lives in the Vulnerabilities-owned DC-30 artifact-consumer SDKStellaOps.VulnMatch.Core(alongsideEcosystemEquivalence, same directory), not underStellaOps.Policy.Engine/Services;PostgresFindingsLookupmerely consumes it. The runtime delegates bucket construction + advisory-row admission to it.- PURL name shapes per ecosystem (preserved by
PurlComponentExtractor.TryParseFull):- npm scoped —
pkg:npm/%40babel/core@7.0.0/pkg:npm/@scope/widget@1.0.0→ name@scope/widget. - Maven —
pkg:maven/org.apache.commons/commons-lang3@3.12.0→ nameorg.apache.commons:commons-lang3(groupId+artifactId joined with:). - Golang —
pkg:golang/github.com/foo/bar@v1.2.3→ namegithub.com/foo/bar(full module path). - composer / pub / swift —
pkg:composer/api-platform/core@v3.4.17→ nameapi-platform/core(the vendor namespace is preserved). Collapsing to the barecorecross-matched every vendor’s*/coreadvisory (e.g.drupal/corevsapi-platform/core), the ~460 false-positive class closed by the composer-namespace precision fix. Single-segment composer/pub/swift PURLs keep their bare name.AdvisoryConverterderivesvuln.advisory_affected.package_namefrom the SAME extractor, so the deployed and advisory keys never drift; the live Concelier migrationsrc/Concelier/__Libraries/StellaOps.Concelier.Persistence/Migrations/009_composer_namespaced_package_name_backfill.sqlre-derives existing rows from their retained PURL. - Everything else (apk/deb/rpm/pypi/nuget/oci/generic) — only the trailing path segment.
- npm scoped —
The post-hoc EcosystemVersionFilter C# pass remains in place as a secondary version-range discriminator; it is no longer the only ecosystem-awareness layer.
5.2 · Scanner Policy-owner seam
Policy owns the Scanner report policy contract at /api/v1/policy/scanner/{schema,diagnostics,preview,rationale}. The wire shapes live in the producer-owned, BCL-only StellaOps.Policy.Scanner.Contracts; Scanner calls them through its IPolicyOwnerClient and never references Policy implementation or storage. Every request carries an explicit tenant, and Policy rejects a tenant that is missing or differs from the authenticated stellaops:tenant claim.
These four machine endpoints require the dedicated policy:preview:invoke scope. They deliberately exclude the resource server’s broader configured policy:read default: the stellaops-scanner-web client is granted the dedicated capability and is intentionally denied broad Policy read authority. The named shared authorization helper preserves bearer authentication, tenant enforcement and authority.resource.authorize auditing; it changes only default-scope composition for these four conformance-pinned routes. All ordinary Policy endpoints continue to combine their endpoint requirements with configured resource defaults. A missing, rejected or unavailable owner call fails the Scanner report path closed rather than falling back to local Policy logic or database access.
6 · Run Orchestration & Incremental Flow
- Change streams: Concelier and Excititor publish document changes to the scheduler queue (
policy.trigger.delta). Payload includestenant,source,linkset digests,cursor. - Orchestrator: Maintains per-tenant backlog; merges deltas until time/size thresholds met, then enqueues
PolicyRunRequest. - Queue: PostgreSQL queue with lease; each job assigned
leaseDuration,maxAttempts. - Workers: Lease jobs, execute evaluation pipeline, report status (success/failure/canceled). Failures with recoverable errors requeue with backoff; determinism or schema violations mark job
failedand raise incident event. - Fairness: Round-robin per
{tenant, policyId}; emergency jobs (priority=emergency) jump queue but limited via circuit breaker. - Replay: On demand, orchestrator rehydrates run via stored cursors and exports sealed bundle for audit/CI determinism checks.
- Batch evaluation service (
/api/policy/eval/batch): Stateless evaluator powering Findings Ledger and replay/offline workflows. Requests contain canonical ledger events plus optional current projection; responses return status/severity/labels/rationale without mutating state. Policy Engine enforces per-tenant cost budgets, caches results by(tenantId, policyVersion, eventHash, projectionHash), and falls back to inline evaluation when the remote service is disabled.
6.1 · VEX decision attestation pipeline
- Verdict capture. Each evaluation result contains
{findingId, cve, productKey, reachabilityState, evidenceRefs}plus SBOM and runtime CAS hashes. - OpenVEX serialization.
VexDecisionEmitterbuilds an OpenVEX document with one statement per(cve, productKey)and fills:status,justification,status_notes,impact_statement,action_statement.products(purl) andevidencearray referencingreachability.json,sbom.cdx.json,runtimeFacts.
- DSSE signing. The emitter calls Signer
POST /api/v1/signer/sign/dssewith predicatestella.ops/vexDecision@v1. Signer verifies PoE + scanner integrity and returns a DSSE envelope (decision.dsse.json). - 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. - Export. API/CLI endpoints expose
decision.openvex.json,decision.dsse.json,rekor.txt, and evidence metadata so Export Center + bench automation can mirror them intobench/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
| Status | Exit Code | Description |
|---|---|---|
Pass | 0 | No blocking issues; safe to deploy |
Warn | 1 | Non-blocking issues detected; configurable pass-through |
Fail | 2 | Blocking 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.
| Endpoint | Header | Algorithm |
|---|---|---|
/docker | X-StellaOps-Signature: sha256=<hex> (preferred) or Docker Authorization: Basic <user:hex> | HMAC-SHA256 over raw body |
/harbor | X-Harbor-Signature: sha256=<hex> | HMAC-SHA256 over raw body |
/generic | X-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/operations/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:
- The live
POST /api/v1/policy/gate/evaluateruntime now resolves gate-bypass auditing through the PostgreSQL-backedPostgresGateBypassAuditRepository, scoped by the current tenant context. When a tenant context is genuinely absent, the adapter falls back deterministically to tenantpublicrather than using a process-local in-memory store. StellaOps.Policy.Gatewaynow uses the same PostgreSQL-backed gate-bypass audit path through the unifiedIStellaOpsTenantAccessor, so the standalone gateway no longer keeps a separate in-memory audit repository for compatibility routes.
Runtime Snapshots And Ledger Exports
- The live snapshot surface now persists engine runtime state in PostgreSQL-owned tables
policy.engine_ledger_exportsandpolicy.engine_snapshots, reached throughPostgresLedgerExportStoreandPostgresSnapshotStorerather than the previous process-local in-memory stores. - Sync and async gate evaluation now materialize a tenant-scoped target snapshot in
policy.engine_snapshotsbefore delta computation. The runtime derives that target snapshot from the latest persistedpolicy.engine_ledger_exportsdocument (or the baseline snapshot’s export), stamps the artifact digest/repository/tag onto the snapshot row, and then passes the real snapshot identifier intoDeltaComputer. IOrchestratorJobStoreandIWorkerResultStorenow resolve to persisted adapters overpolicy.orchestrator_jobsandpolicy.worker_results, so Policy export/bootstrap logic survives host recreates instead of depending on process-local completed-job state.- Direct
/policy/orchestrator/jobssubmissions now use a real producer runtime.OrchestratorJobService.SubmitAsyncsignalsPolicyOrchestratorJobWorkerHost, the host leases the next queued job fromIOrchestratorJobStore, marks itrunning, executesPolicyWorkerService, persistspolicy.worker_results, and records terminalcompletedorfailedstate instead of requiring a separate manual/policy/worker/runcall. IReachabilityFactsOverlayCacheresolves toMessagingReachabilityFactsOverlayCachebacked by the host’s single signed messaging backend. Canonical Router-enabled Compose uses persistent Valkey; a Router-disabled standalone host uses transport-owned PostgreSQLmessaging.cache_*tables on the Policy DSN. The live invalidation path uses normalized logical keys instead of the old double-prefixedrf:rf:*shape that broke tenant invalidation after cutover.- The deterministic
/api/policy/eval/batchsurface remains stateless by contract. It returns evaluation results to callers but does not populatepolicy.orchestrator_jobsorpolicy.worker_results. - When a gate request omits an explicit baseline reference and the tenant has no persisted baseline snapshot yet, the engine denies by default with
baselineStatus: "missing:block"andblockedBy: "missing-baseline". Non-prod callers may requestmissingBaselineMode: "warn"for an auditable warning ormissingBaselineMode: "bootstrap"to create the first baseline. Explicit baseline references remain strict: if the caller asks for a missing snapshot ID, the runtime fails instead of inventing one. StellaOps.Policy.Persistencenow applies startup migrations for the Policy schema onpolicy-engineboot, and001_v1_policy_baseline.sqlis idempotent on reused local volumes so snapshot/export runtime convergence does not depend on a fresh database.- The merged gateway compatibility routes now register the unified StellaOps tenant accessor and middleware alongside the Policy-specific tenant context middleware. This keeps copied
RequireTenant()filters from failing pre-handler with500and allows the persisted delta compatibility path to reach the realDeltaComputer. - The live delta compatibility surface now projects persisted engine snapshots through
PersistedKnowledgeSnapshotStoreandDeltaSnapshotServiceAdapter, so tenant-scoped/api/policy/deltas/computerequests fail only on normal contract/data issues rather than process-local tenant or snapshot-store gaps. StellaOps.Policy.Gatewaynow uses the same persisted delta projection path for its standalone compatibility host:ISnapshotStoreresolves toPersistedKnowledgeSnapshotStoreandStellaOps.Policy.Deltas.ISnapshotServiceresolves to the engine-ownedDeltaSnapshotServiceAdapter, replacing the oldInMemorySnapshotStorepath that fabricated mostly-empty compatibility input.
{
"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
# Gate status lookup is not implemented; this command prints that fact and exits 9
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 samples (GitHub Actions; generate with stella ci init), CI/CD gate flow, Signer dossier
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
| Gate | Purpose | Configuration Key |
|---|---|---|
| MinimumConfidenceGate | Reject verdicts below confidence threshold per environment | gates.minimumConfidence |
| UnknownsBudgetGate | Fail scan if unknowns exceed budget | gates.unknownsBudget |
| SourceQuotaGate | Prevent single-source dominance without corroboration | gates.sourceQuota |
| ReachabilityRequirementGate | Require reachability proof for critical CVEs | gates.reachabilityRequirement |
| EvidenceFreshnessGate | Reject stale evidence below freshness threshold | gates.evidenceFreshness |
| CvssThresholdGate | Block findings above CVSS score threshold | gates.cvssThreshold |
| SbomPresenceGate | Require valid SBOM for release artifacts | gates.sbomPresence |
| SignatureRequiredGate | Require signatures on specified evidence types | gates.signatureRequired |
| FacetQuotaGate | Enforce per-facet drift quotas from a pre-computed FacetDriftReport (block/warn/seal-action when report absent) | FacetQuotaGateOptions |
| FixChainGate | Require fix-chain verification for critical vulnerabilities (see Fix Chain Gates) | FixChainGateOptions |
| VexProofGate | Validate VEX proof objects meet policy requirements (confidence tier, signed-statement status, DSSE digest, Rekor index) | VexProofGateOptions |
All eleven gates implement
IPolicyGateand live undersrc/Policy/__Libraries/StellaOps.Policy/Gates/. The five threshold/presence gates andMinimumConfidence/Unknowns/SourceQuota/Reachability/EvidenceFreshnessare covered byetc/policy-gates.yaml.sample;FacetQuotaGate,FixChainGate, andVexProofGatebind 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
- Behavior:
affectedstatus bypasses this gate (conservative default). - Result:
confidence_below_thresholdwhen verdict confidence < environment threshold.
UnknownsBudgetGate
Limits exposure to unknown/unscored dependencies:
gates:
unknownsBudget:
enabled: true
maxUnknownCount: 5
maxCumulativeUncertainty: 2.0
escalateOnExceed: true
- Behavior: Fails when unknowns exceed count limit OR cumulative uncertainty exceeds budget.
- Cumulative uncertainty:
sum(1 - ClaimScore)across all verdicts.
SourceQuotaGate
Prevents single-source verdicts without corroboration:
gates:
sourceQuota:
enabled: true
maxInfluencePercent: 60
corroborationDelta: 0.10
requireCorroboration: true
- Behavior: Fails when single source provides > 60% of verdict weight AND no second source is within delta (0.10).
- Rationale: Ensures critical decisions have multi-source validation.
ReachabilityRequirementGate
Requires reachability proof for high-severity vulnerabilities:
gates:
reachabilityRequirement:
enabled: true
applySeverities:
- critical
- high
exemptStatuses:
- not_affected
bypassReasons:
- component_not_present
- Behavior: Fails when CRITICAL/HIGH CVE marked
not_affectedlacks reachability proof (unless bypass reason applies).
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
- Behavior: Fails when CVSS base score exceeds environment threshold.
- CVSS Versions: Supports both CVSS v3.1 and v4.0; preference configurable.
- Allowlist: CVEs that bypass threshold enforcement.
- Denylist: CVEs that always fail regardless of score.
- Offline: Operates without external lookups; uses injected or metadata scores.
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
- Enforcement Levels:
required(fail),recommended(warn),optional(pass). - Format Validation: Validates SBOM format against accepted list; normalizes format names.
- Schema Validation: Validates SBOM against bundled JSON schemas.
- Signature Requirement: Optionally requires signed SBOM.
- Minimum Components: Ensures SBOM has meaningful inventory.
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
- Per-Evidence-Type: Configure requirements per evidence type (SBOM, VEX, attestation).
- Issuer Constraints: Wildcard support (
*@domain.com) for email patterns. - Algorithm Enforcement: Limit accepted signature algorithms.
- Keyless (Fulcio): Support Sigstore keyless signatures with Fulcio certificate verification.
- Transparency Log: Optionally require Rekor inclusion proof.
- Environment Overrides: Relax requirements for non-production environments.
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:
policy_gate_evaluations_total{tenant,source,status}- Count of gate evaluation jobs by tenant, request source, and final status (Accepted,Rejected,ManualReview, orerror).policy_gate_evaluation_duration_seconds{tenant,source,status}- Gate evaluation job latency histogram with the same tags. Exceptions recordstatus=errorbefore the exception is rethrown so error-rate dashboards have a denominator.
Gate Implementation Reference
| Gate | Source File |
|---|---|
| MinimumConfidenceGate | src/Policy/__Libraries/StellaOps.Policy/Gates/MinimumConfidenceGate.cs |
| UnknownsBudgetGate | src/Policy/__Libraries/StellaOps.Policy/Gates/UnknownsBudgetGate.cs |
| SourceQuotaGate | src/Policy/__Libraries/StellaOps.Policy/Gates/SourceQuotaGate.cs |
| ReachabilityRequirementGate | src/Policy/__Libraries/StellaOps.Policy/Gates/ReachabilityRequirementGate.cs |
| EvidenceFreshnessGate | src/Policy/__Libraries/StellaOps.Policy/Gates/EvidenceFreshnessGate.cs |
| CvssThresholdGate | src/Policy/__Libraries/StellaOps.Policy/Gates/CvssThresholdGate.cs |
| SbomPresenceGate | src/Policy/__Libraries/StellaOps.Policy/Gates/SbomPresenceGate.cs |
| SignatureRequiredGate | src/Policy/__Libraries/StellaOps.Policy/Gates/SignatureRequiredGate.cs |
| FacetQuotaGate | src/Policy/__Libraries/StellaOps.Policy/Gates/FacetQuotaGate.cs |
| FixChainGate | src/Policy/__Libraries/StellaOps.Policy/Gates/FixChainGate.cs |
| VexProofGate | src/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 Level | Description | Required Approvers |
|---|---|---|
| G0 | Development | Auto-approve (no approval needed) |
| G1 | Testing/QA | 1 peer reviewer |
| G2 | Staging | Code owner approval |
| G3 | Pre-production | Delivery Manager + Product Manager |
| G4 | Production | CISO + 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 (Created → Activated 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
- Approved exceptions automatically expire after TTL
- Background worker marks expired requests as
Expired - Gate evaluation checks
expires_atbefore honoring exception
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
| Component | Source File |
|---|---|
| Entities | src/Policy/__Libraries/StellaOps.Policy.Persistence/Postgres/Models/ExceptionApprovalEntity.cs |
| Repository | src/Policy/__Libraries/StellaOps.Policy.Persistence/Postgres/Repositories/ExceptionApprovalRepository.cs |
| Rules Service | src/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 Commands | src/Cli/StellaOps.Cli/Commands/ExceptionCommandGroup.cs |
| Migration | policy.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.
GET /api/v1/governance/trust-weightsPUT /api/v1/governance/trust-weights/{weightId}POST /api/v1/governance/trust-weights/preview-impactGET /api/v1/governance/staleness/configPUT /api/v1/governance/staleness/config/{dataType}GET /api/v1/governance/staleness/statusGET /api/v1/governance/conflicts/dashboardGET /api/v1/governance/conflictsPOST /api/v1/governance/conflicts/analyzePOST /api/v1/governance/conflicts/{conflictId}/acknowledgePOST /api/v1/governance/conflicts/{conflictId}/resolvePOST /api/v1/governance/conflicts/{conflictId}/ignore
Contract requirements:
- All requests are tenant-scoped and may include an optional
projectId. - Console clients must resolve live tenant scope from the active session/context and must not rely on legacy placeholder aliases.
- The merged
StellaOps.Policy.Enginehost serves sealed-mode status/toggle from persisted sealed-mode state and servesrisk-profilesas configuration-backed read-only data. - Compatibility routes without a durable backend (
trust-weights,staleness,conflicts, governance audit/history, risk-budget dashboards, sealed-mode overrides, and risk-profile mutation/lifecycle actions) must return truthful501 Not Implementedresponses rather than fabricate tenant-local state. - The standalone
StellaOps.Policy.Gatewayhost does not own governance state and therefore returns truthful501responses across this compatibility surface unless a future sprint wires a real backend.
Implementation reference:
src/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GovernanceEndpoints.cssrc/Policy/StellaOps.Policy.Engine/Endpoints/Gateway/GovernanceCompatibilityEndpoints.cssrc/Policy/StellaOps.Policy.Gateway/Endpoints/GovernanceCompatibilityEndpoints.cssrc/Policy/StellaOps.Policy.Gateway/Endpoints/GovernanceEndpoints.cs
7 · Security & Tenancy
- Auth: Endpoints are protected by
AddStellaOpsResourceServerAuthentication(JWT bearer resource-server validation) plus the StellaOps scope handler; service-to-service callers use Authority client-credentials (policy-engineclient). The resource server accepts the platform-wideaud=stellaopsalongside any configured audiences, and bypass-network callers are handled byStellaOpsBypassEvaluator. (Note: the engine host does not itself enforce DPoP sender-constrained tokens; DPoP, where required, is enforced upstream at the Authority/gateway layer.) - Scopes: Mutations require the relevant
policy:*scope (policy:write,policy:author,policy:edit,policy:review,policy:submit,policy:approve,policy:operate,policy:publish,policy:promote,policy:audit,policy:run,policy:activate,policy:simulate); read access usespolicy:read. Materialised-finding reads usefindings:read;effective:writeis restricted to the Policy Engine service identity. Exception flows useexceptions:read/exceptions:write/exceptions:request/exceptions:approve; release-gate override usesrelease:bypass. - Tenancy: Tenant identity resolves from the envelope-bound
stellaops:tenantclaim viaIStellaOpsTenantAccessor/ReplicaStellaOpsTenantResolver(slug → canonical tenant UUID), served from Policy’s OWN replica of Authority’s tenants catalog instellaops_policy— not from a cross-database read ofshared.tenants, which does not exist in Policy’s database. Registered byAddStellaOpsTenantResolverReplicaOnly; withCatalog:Replication:Tenants:Enabledoff the host REFUSES to resolve rather than answeringnull, because a null would report a replication fault as a verdict about the tenant. All queries filter by tenant and requests without a resolved tenant fail closed with HTTP 401. There is nopolicy:tenant-adminscope. - Secrets: Configuration loaded via environment variables or sealed secrets; runtime avoids writing secrets to logs.
- Determinism guard: Static analyzer prevents referencing forbidden namespaces; runtime guard intercepts
DateTime.Now,Random,Guid, HTTP clients beyond allow-list. - Sealed mode: Global flag disables outbound network except allow-listed internal hosts; watchers fail fast if unexpected egress attempted.
Determinism enforcement (DOCS-POLICY-DET-01)
- Inputs are ordered and frozen: Selector emits batches sorted deterministically by
(tenant, policyId, vulnerabilityId, productKey, source)with stable cursors; workers must not resort. - No ambient randomness or wall clocks: Policy code relies on injected
TimeProvider/IRandomshims; guards blockDateTime.Now,Guid.NewGuid,Randomwhen not injected. - Immutable evidence: SBOM/VEX inputs carry content hashes; evaluator treats payloads as read-only and surfaces hashes in logs for replay.
- Side effects prohibited: Evaluator cannot call external HTTP except allow-listed internal services (Authority, Storage) and must not write files outside temp workspace.
- Replay hash: Each batch computes
determinismHash = SHA256(policyVersion + batchCursor + inputsHash); included in logs and run exports. - Testing: Determinism tests run the same batch twice with seeded clock/GUID providers and assert identical outputs + determinismHash; add a test per policy package.
8 · Observability
Metrics are emitted from StellaOps.Policy.Engine/Telemetry/PolicyEngineTelemetry.cs (consult the source for the authoritative tag set). Representative instruments:
- Run / evaluation:
policy_run_seconds(histogram),policy_run_queue_depth,policy_rules_fired_total,policy_vex_overrides_total,policy_evaluations_total,policy_evaluation_latency_seconds,policy_findings_materialized_total,policy_concurrent_evaluations,policy_worker_utilization. - API:
policy_requests_total,policy_api_latency_seconds,policy_api_errors_total,policy_errors_total,policy_evaluation_failures_total,policy_rate_limit_exceeded_total. - SLO:
policy_slo_burn_rate,policy_error_budget_remaining,policy_slo_violations_total. - Entropy signals:
policy_entropy_penalty_total,policy_entropy_penalty_value,policy_entropy_image_opaque_ratio,policy_entropy_top_file_ratio. - Risk scoring:
policy_risk_scoring_jobs_created_total,policy_risk_scoring_triggers_skipped_total. - Determinization (
StellaOps.Policy.Determinization):stellaops_determinization_uncertainty_entropy,stellaops_determinization_decay_multiplier. Proof Studio (StellaOps.Policy.Explainability):stellaops.proofstudio.views_composed_total,stellaops.proofstudio.counterfactuals_applied_total. - Logs: Structured JSON with
traceId,policyId,version,runId,tenant,phase. Guard ensures no sensitive data leakage. - Traces: Spans
policy.select,policy.evaluate,policy.materialize,policy.simulate. Trace IDs surfaced to CLI/UI. - Incident mode toggles 100 % sampling and extended retention windows.
9 · Offline / Bundle Integration
- Imports: Offline Kit delivers policy packs, advisory/VEX snapshots, SBOM updates. Policy Engine ingests bundles via
offline import. - Exports:
stella policy bundle exportpackages policy, IR digest, simulations, run metadata; UI provides export triggers. - Sealed hints: Explain traces annotate when cached values used (EPSS, KEV). Run records mark
env.sealed=true. - Sync cadence: Operators perform monthly bundle sync; Policy Engine warns when snapshots > configured staleness (default 14 days).
10 · Testing & Quality
- Unit tests: DSL parsing, evaluator semantics, guard enforcement.
- Integration tests: Joiners with sample SBOM/advisory/VEX data; materialisation with deterministic ordering; API contract tests generated from OpenAPI.
- Property tests: Ensure rule evaluation deterministic across permutations.
- Golden tests: Replay recorded runs, compare determinism hash.
- Snapshot contract (Policy Engine tests): Snapshot assertions resolve to source-controlled
src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Snapshots/via caller-file path. Regenerate withUPDATE_SNAPSHOTS=1only when intentional fixture changes are reviewed. - API auth fixture contract (PolicyEngineApiHostTests): Test auth overrides run in fixture scope only, with deterministic in-memory resource-server settings (
Authority,RequireHttpsMetadata=false) and canonical tenant claimstellaops:tenantso tenancy middleware and scope policies both evaluate in tests. - Consumer API contract tests:
StellaOps.Policy.Engine.Contract.Testsuses managed in-memory HTTP handlers and source-defined JSON assertions instead of PactNet/libpact_ffi so the contract suite remains runnable on glibc Linux ARM64 runners. - Registry boundary tests: Focused Policy Engine tests should target
src/Policy/__Tests/StellaOps.Policy.Engine.Tests/StellaOps.Policy.Engine.Tests.csprojfor policy pack bundle import, trust verification, and confirmation that pack import cannot execute pack-provided code. Executable evaluator adapters are not a default Policy path; if a future sprint adds one, signed-bundle admission tests belong in that module-local sprint and must use the shared plugin admission chain. - Performance tests: Evaluate 100k component / 1M advisory dataset under warmed caches (<30 s full run).
- Chaos hooks: Optional toggles to simulate upstream latency/failures; used in staging.
11 · Compliance Checklist
- [ ] Determinism guard enforced: Static analyzer + runtime guard block wall-clock, RNG, unauthorized network calls.
- [ ] Incremental correctness: Change-stream cursors stored and replayed during tests; unit/integration coverage for dedupe.
- [ ] RBAC validated: Endpoint scope requirements match Authority configuration; integration tests cover deny/allow.
- [ ] AOC separation enforced: No code path writes to
advisory_raw/vex_raw; integration tests captureERR_AOC_00xhandling; read-only clients verified. - [ ] Effective findings ownership: Only Policy Engine identity holds
effective:write; unauthorized callers receiveERR_AOC_006. - [ ] Observability wired: Metrics/traces/logs exported with correlation IDs; dashboards include
aoc_violation_totaland ingest latency panels. - [ ] Offline parity: Sealed-mode tests executed; bundle import/export flows documented and validated.
- [ ] Schema docs synced: DTOs match Scheduler Models (
SCHED-MODELS-20-001); JSON schemas committed. - [ ] Security reviews complete: Threat model (including queue poisoning, determinism bypass, data exfiltration) documented; mitigations in place.
- [ ] Disaster recovery rehearsed: Run replay+rollback procedures tested and recorded.
12 · Related Product Advisories
Archived advisories below provide historical design context, not current capability or runtime authority. Re-verify their claims against the owning source and current contracts before use.
Historical advisory: Diff-Aware Release Gates and Risk Budgets — Frozen design context for risk budgets, delta verdicts, VEX trust scoring and release gates. Historical sections:
- §2 Risk Budget Model: Service tier definitions and RP scoring formulas
- §4 Delta Verdict Engine: Deterministic evaluation pipeline and replay contract
- §5 Smart-Diff Algorithm: Material risk change detection rules
- §7 VEX Trust Scoring: Confidence/freshness lattice for VEX source weighting
Historical advisory: Deterministic Evidence and Verdict Architecture — Frozen design context for determinism, canonical serialization and signing. Historical sections:
- §3 Canonical Serialization: RFC 8785 JCS + Unicode NFC rules
- §5 Signing & Attestation: Keyless signing with Sigstore
- §6 Proof-Carrying Reachability: Minimal proof chains
- §8 Engine Architecture: Deterministic evaluation pipeline
Determinism Specification — Technical specification for all digest algorithms (VerdictId, EvidenceId, GraphRevisionId, ManifestId) and canonicalization rules.
Historical advisory: Smart-Diff Technical Reference — Frozen algorithm-design context for reachability gates, delta computation and call-stack analysis.
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
| Format | Schema | Direction | Notes |
|---|---|---|---|
| PolicyPack v2 (JSON) | policy.stellaops.io/v2 | Import + Export | Canonical format with typed gates, environment overrides, remediation hints |
| PolicyPack v2 (YAML) | policy.stellaops.io/v2 | Import + Export | Deterministic YAML with sorted keys; YAML→JSON roundtrip for validation |
| OPA/Rego | package stella.release | Export (+ 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 Type | Rego Pattern | Remediation Code |
|---|---|---|
CvssThresholdGate | input.cvss.score >= threshold | CVSS_EXCEED |
SignatureRequiredGate | not input.dsse.verified | SIG_MISS |
EvidenceFreshnessGate | not input.freshness.tstVerified | FRESH_EXPIRED |
SbomPresenceGate | not input.sbom.canonicalDigest | SBOM_MISS |
MinimumConfidenceGate | input.confidence < threshold | CONF_LOW |
UnknownsBudgetGate | input.unknownsRatio > threshold | UNK_EXCEED |
ReachabilityRequirementGate | not input.reachability.status | REACH_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:
- Same policy + same input = same output (hash-verifiable)
- Exports include SHA-256
digestfield - No time-dependent logic in deterministic mode
outputDigestin evaluation results enables replay verification
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):
- Converts
PolicyPackDocumentto aSortedDictionaryintermediate for deterministic key ordering - Serializes via YamlDotNet (CamelCaseNamingConvention, DisableAliases, OmitNull)
- Produces SHA-256 digest for replay verification
- Supports environment filtering and remediation stripping (same options as JSON)
Import (YamlPolicyImporter):
- Deserializes YAML via YamlDotNet, then re-serializes as JSON
- Delegates to
JsonPolicyImporterfor validation (apiVersion, kind, duplicate gates/rules) - Errors:
YAML_PARSE_ERROR,YAML_EMPTY,YAML_CONVERSION_ERROR
Format Detection (FormatDetector):
- Content-based: detects
apiVersion:,---,kind:patterns - Extension-based:
.yaml,.yml→PolicyFormats.Yaml
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:
- Changes to metadata (name, version, description)
- Changes to settings (defaultAction, unknownsThreshold, stopOnFirstFailure, deterministicMode)
- Gate changes (by ID): added, removed, modified (action, type, config diffs)
- Rule changes (by Name): added, removed, modified (action, match diffs)
- Summary with counts of added/removed/modified and
HasChangesflag
Merge applies one of three strategies via PolicyMergeStrategy:
| Strategy | Behavior |
|---|---|
OverlayWins | Overlay values take precedence on conflict |
BaseWins | Base values take precedence on conflict |
FailOnConflict | Returns 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
| Component | Source File |
|---|---|
| Contracts | src/Policy/__Libraries/StellaOps.Policy.Interop/Contracts/PolicyPackDocument.cs |
| Remediation Models | src/Policy/__Libraries/StellaOps.Policy.Interop/Contracts/RemediationModels.cs |
| Interfaces | src/Policy/__Libraries/StellaOps.Policy.Interop/Abstractions/ |
| JSON Exporter | src/Policy/__Libraries/StellaOps.Policy.Interop/Export/JsonPolicyExporter.cs |
| YAML Exporter | src/Policy/__Libraries/StellaOps.Policy.Interop/Export/YamlPolicyExporter.cs |
| JSON Importer | src/Policy/__Libraries/StellaOps.Policy.Interop/Import/JsonPolicyImporter.cs |
| YAML Importer | src/Policy/__Libraries/StellaOps.Policy.Interop/Import/YamlPolicyImporter.cs |
| Rego Generator | src/Policy/__Libraries/StellaOps.Policy.Interop/Rego/RegoCodeGenerator.cs |
| Rego Importer | src/Policy/__Libraries/StellaOps.Policy.Interop/Import/RegoPolicyImporter.cs |
| Diff/Merge Engine | src/Policy/__Libraries/StellaOps.Policy.Interop/DiffMerge/PolicyDiffMergeEngine.cs |
| Embedded OPA | src/Policy/__Libraries/StellaOps.Policy.Interop/Evaluation/EmbeddedOpaEvaluator.cs |
| Remediation Resolver | src/Policy/__Libraries/StellaOps.Policy.Interop/Evaluation/RemediationResolver.cs |
| Format Detector | src/Policy/__Libraries/StellaOps.Policy.Interop/Import/FormatDetector.cs |
| Schema Validator | src/Policy/__Libraries/StellaOps.Policy.Interop/Validation/PolicySchemaValidator.cs |
| DI Registration | src/Policy/__Libraries/StellaOps.Policy.Interop/DependencyInjection/PolicyInteropServiceCollectionExtensions.cs |
| CLI Commands | src/Cli/StellaOps.Cli/Commands/Policy/PolicyInteropCommandGroup.cs |
| Policy Engine API | src/Policy/StellaOps.Policy.Engine/Interop/Endpoints/PolicyInteropEndpoints.cs |
| JSON Schema | docs/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
| Method | Path | Auth Policy | Description |
|---|---|---|---|
| POST | /export | policy:read | Export policy to format |
| POST | /import | policy:write | Import policy from format |
| POST | /validate | policy:read | Validate policy document |
| POST | /evaluate | policy:read | Evaluate policy against input |
| GET | /formats | policy:read | List 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 Field | Type | Description |
|---|---|---|
artifact.digest | string | Artifact digest (e.g., sha256:abc...) |
artifact.mediaType | string | OCI media type |
artifact.reference | string | Full artifact reference |
sbom.digest | string | SBOM content hash |
sbom.format | string | Format identifier (e.g., cyclonedx-1.7, spdx-3.0.1) |
sbom.componentCount | int | Number of components |
sbom.content | object | Optional inline SBOM JSON |
attestations[] | array | Attestation references |
attestations[].digest | string | DSSE envelope digest |
attestations[].predicateType | string | in-toto predicate type URI |
attestations[].signatureVerified | bool | Signature verification status |
attestations[].rekorLogIndex | long | Transparency log index |
transparency.rekor[] | array | Rekor receipts |
transparency.rekor[].logId | string | Log identifier |
transparency.rekor[].uuid | string | Entry UUID |
transparency.rekor[].logIndex | long | Log position |
transparency.rekor[].integratedTime | long | Unix timestamp |
transparency.rekor[].verified | bool | Receipt verification status |
vex.mergeDecision | object | VEX merge decision |
vex.mergeDecision.algorithm | string | Merge algorithm (e.g., trust-weighted-lattice-v1) |
vex.mergeDecision.inputs[] | array | Source documents with trust weights |
vex.mergeDecision.decisions[] | array | Per-vulnerability decisions with provenance |
Code locations:
- Evidence models:
src/Policy/__Libraries/StellaOps.Policy/Gates/Opa/OpaEvidenceModels.cs - Context extension:
src/Policy/__Libraries/StellaOps.Policy/Gates/PolicyGateAbstractions.cs - Input builder:
src/Policy/__Libraries/StellaOps.Policy/Gates/Opa/OpaGateAdapter.cs
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 Type | Depth | Purpose |
|---|---|---|
Verdict | 0 | Root: the final verdict + composite score |
PolicyRule | 1 | Policy clause that triggered the decision |
Guardrail | 1 | Score guardrail (cap/floor) that modified the score |
ScoreComputation | 2 | Per-factor score contribution |
ReachabilityAnalysis | 3 | Reachability evidence leaf |
VexStatement | 3 | VEX attestation leaf |
Provenance | 3 | Provenance attestation leaf |
SbomEvidence | 3 | SBOM evidence leaf |
RuntimeSignal | 3 | Runtime detection signal leaf |
AdvisoryData | 3 | Advisory data leaf |
Counterfactual | 0 | What-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:
- Creates a
Counterfactualnode at depth 0 with the scenario label. - Connects overridden factor score nodes to the counterfactual node via
Overridesedges. - Recomputes the content-addressed graph ID, making each scenario distinctly identifiable.
14.5 · Proof Studio Service (Integration)
The IProofStudioService is the primary integration surface:
| Method | Input | Output |
|---|---|---|
Compose(request) | ProofStudioRequest (rationale + optional score factors + guardrails) | ProofStudioView (proof graph + optional score breakdown) |
ApplyCounterfactual(view, scenario) | Existing view + CounterfactualScenario | Updated 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
| Metric | Type | Description |
|---|---|---|
stellaops.proofstudio.views_composed_total | Counter | Proof studio views composed |
stellaops.proofstudio.counterfactuals_applied_total | Counter | Counterfactual scenarios applied |
14.8 · Tests
ProofGraphBuilderTests.cs— 18 tests (graph construction, determinism, depth hierarchy, critical paths, counterfactual overlay, edge cases)ProofStudioServiceTests.cs— 10 tests (compose, score breakdown, guardrails, counterfactual, DI resolution)
15 · Advisory Gap Status (2026-03-04 Batch)
Status: implementation delivered in Sprint 306.
ScorePolicyruntime contract now includes requiredPolicyId;ScorePolicy.Defaultemits deterministic IDscore-policy.default.v1.- Loader and validator behavior is aligned:
ScorePolicyLoaderenforcespolicyVersion, requiredpolicyId, schema validation, and deterministic load failures.- Missing
policyIdnow fails predictably with explicit error text.
- Schema ownership is canonicalized:
- runtime validator loads one canonical schema resource (
Schemas/score-policy.v1.schema.json) embedded inStellaOps.Policy. - source schema and embedded resource parity are guarded by tests.
- runtime validator loads one canonical schema resource (
- Section naming drift was removed; schema keys align with runtime serialization (
reachability,evidence,provenance,scoringProfile). - Existing policy tests and fixtures that build
ScorePolicywere updated to include deterministicpolicyId.
Legacy fixture note:
- Older YAML fixtures without
policyIdare no longer valid and must be migrated by adding deterministicpolicyIdvalues.
Closure sprint:
docs/implplan/SPRINT_20260304_306_Policy_score_policy_contract_consistency.md
Scanner report policy publication
Scanner report preview reads the authenticated tenant’s latest immutable report-policy snapshot from Policy-owned storage. It does not select a policy pack or reinterpret compiled policy DSL. Fresh installations publish the missing initial snapshot with POST /api/policy/scanner-report-snapshots, using both policy:edit and policy:activate. The request contains expectedRevision (zero only when absent) and a policy object conforming to the existing Policy JSON schema. Policy validates the document before writing, records the authenticated subject and timestamp, and emits the existing snapshot audit action. A stale revision returns 409; the existing unique tenant/policy/version constraint also prevents concurrent overwrites.
This endpoint appends report-policy versions in policy.snapshots; it does not change published policy packs, pack approval records, release bindings, or gate activation. Requests without a valid tenant, required scopes, or a valid policy remain refused. Scanner’s report response identifies the selected revision and policy digest, and its normal signing/attestation flow remains required.
