Policy Verdict Attestations
Status: Live and proven end-to-end on the production evaluate paths (SPRINT_20260704_005, PVA-1…3; proof captured 2026-07-16). Default-ON per PLAN_20260704 decision D1; degrades to
unattestedwhen the Attestor is unavailable or misconfigured. The Production wiring section below is authoritative and is the contract sprint 006 consumes; the older “Implementation Guide” further down describes the per-finding batch model and is illustrative, not the live path. Predicate URI:https://stellaops.dev/predicates/policy-verdict@v1Schema:docs/modules/policy/schemas/stellaops-policy-verdict.v1.schema.json
Overview
This guide describes Stella Ops verdict attestations for engineers integrating the Policy Engine, Attestor, and Evidence Locker, and for auditors who need to verify a verdict’s provenance.
Verdict attestations provide cryptographically-bound proof that a policy evaluation verdict (passed/warned/blocked/quieted) was issued for a specific finding at a specific time with specific evidence. Production policy evaluations attach a post-decision verdict-attestation reference to their response (see Production wiring below), wrapped in DSSE envelopes on the Attestor, enabling:
- Trust & Integrity: Cryptographic proof verdicts haven’t been tampered with
- Audit & Compliance: Standalone artifacts for incident review, regulatory compliance
- Automation: Downstream tools can verify verdict authenticity before acting
- Transparency: Optional anchoring in Rekor transparency log
Architecture
Flow
Policy Engine
↓ (evaluates finding)
PolicyExplainTrace
↓ (mapped to)
VerdictPredicate (canonical JSON)
↓ (sent to)
Attestor Service
↓ (signs with DSSE)
Verdict Attestation (signed envelope)
↓ (stored in)
Evidence Locker (PostgreSQL + object store)
↓ (optionally anchored in)
Rekor Transparency Log
Components
| Component | Responsibility | Location |
|---|---|---|
| PolicyExplainTrace | Policy evaluation result | StellaOps.Scheduler.Models |
| VerdictPredicateBuilder | Maps trace → predicate | StellaOps.Policy.Engine.Attestation |
| VerdictAttestationService | Sends attestation requests | StellaOps.Policy.Engine.Attestation |
| VerdictAttestationCoordinator | Maps a live evaluation → trace → attestation ref; guarantees non-interference | StellaOps.Policy.Engine.Attestation |
| VerdictController | Receives, signs (server-side DSSE), stores | StellaOps.Attestor (POST /internal/api/v1/attestations/verdict) |
| Evidence Locker | Stores and indexes verdicts | StellaOps.EvidenceLocker |
Production wiring (SPRINT_20260704_005) — the live evaluate paths and attestationRef
This is the authoritative description of how verdict attestations reach the running stack. VerdictAttestationCoordinator attaches a post-decision attestation reference to the two production evaluation responses; it is registered as a singleton in Policy.Engine/Program.cs.
Which responses carry the ref
| Path | Endpoint | Response type | Consumed by |
|---|---|---|---|
| Pack-revision evaluate (primary) | POST /api/policy/packs/{packId}/revisions/{version}/evaluate | PolicyEvaluationResponse.attestationRef | Release-orchestrator’s HttpPolicyEngineGateClient; sprint 006 records the ref + basis into gate_decisions |
| Drift gate (secondary) | POST /api/v1/policy/gate/evaluate | GateEvaluateResponse.AttestationRef | CLI/CI callers |
The pack-revision path is the one RO’s gate client actually calls, so it is the load-bearing path for the evidence chain. For the pack path the attestation is computed once per unique (bundleDigest|subject) and cached with the response, so a cache hit returns the same attestation state.
attestationRef shape (contract)
Both responses expose the same object (nullable / additive):
{
"status": "attested", // "attested" | "unattested"
"verdictId": "verdict-<sha256>", // Attestor-assigned id; null when unattested
"attestationUri": "/api/v1/verdicts/verdict-<sha256>", // retrieval URI; null when unattested
"predicateType": "https://stellaops.dev/predicates/policy-verdict@v1",
"determinismHash": "sha256:<hex>", // reproducible-by-reference anchor; present even when unattested
"rekorLogIndex": null, // set only when anchored (OnlineRekor); null in the offline posture
"reason": null // populated with the failure reason when status = "unattested"
}
Rules a consumer (sprint 006) can rely on:
- Field is absent entirely ⇒ attestation is turned off by config (
VerdictAttestation:Enabled=false). Not a failure; do not treat asunattested. status="attested"⇒ the Attestor accepted the verdict;verdictId/attestationUriresolve,determinismHashpins the decision.status="unattested"⇒ the verdict stands unchanged; attestation could not be produced (Attestor down / feature-gated / no trust roster).reasonexplains why;determinismHashis still present because the canonical predicate is computed locally, independent of Attestor availability.
Non-interference (binding)
Attestation is strictly post-decision and never changes the verdict. Every failure mode — disabled, predicate-build error, Attestor unreachable/501/503, or the service configured to throw (FailOnError) — is contained by the coordinator and degrades to unattested with a loud log. A verdict response’s decision/status is byte-for-byte identical whether or not attestation succeeds.
What the predicate pins (basis) — PVA-2
The predicate metadata records the real basis available at the evaluation data layer, and marks everything it genuinely does not have as absent (never fabricated):
| Metadata key | Pack path | Drift path |
|---|---|---|
policypackdigest | compiled bundle digest (real) | absent (drift gate has no compiled bundle) |
findingshash | sha256 over the matched CVE set (real) | sha256 over the gate-result set (real) |
totalfindingscves, denylistrulecount, subjectreleaseid | real from the evaluation evidence | — |
baselinesnapshotid, targetsnapshotid, deltaid | — | real snapshot/delta refs |
advisorysnapshotdigest, vexsnapshotdigest, feedsnapshotdigest | absent (finding C4 — not at this layer yet) | absent |
The determinismHash is computed over the full canonical decision including this basis metadata (SPRINT_20260704_005 fixed VerdictPredicateBuilder so the stamped hash equals ComputeDeterminismHash(predicate)). It is therefore:
- stable across two identical evaluations (it excludes
runIdand the evaluation timestamp), and - sensitive to any change in the pinned basis — e.g. a different policy-pack digest or a changed findings set yields a different hash.
Default-on posture and the off-switch
Per PLAN_20260704 D1 the base stack signs verdicts by default. In code this is VerdictAttestationOptions.Enabled = true with TransparencyMode = Offline (offline-first; also required so the production ValidateOnStart guard passes, since policy-engine runs with the default ASP.NET environment = Production). Operators disable it per-profile with VerdictAttestation:Enabled=false, or opt into transparency-log anchoring with TransparencyMode=OnlineRekor plus a real Rekor submission client.
Live status (verified 2026-07-16)
The base compose profile provisions the Policy Engine’s offline Attestor URL/key, the Attestor trust roster, and signed tenant-bound service identities for both internal hops. The coherent Policy Engine, Attestor, and Evidence Locker Web images were deployed and a fresh pack-revision evaluation returned status="attested". Attestor returned HTTP 201, Evidence Locker durably stored the record, and gateway retrieval returned the exact pack id/version, evaluation run id, subject, and determinism hash.
The sanitized evidence is under docs/qa/feature-checks/runs/2026-07-16-policy-verdict-attestation-live/. The separate POST /api/v1/verdicts/{id}/verify forcing function currently fails closed when verification trust roots are unavailable; do not interpret successful creation/retrieval as proof of offline signature verification until that trust-root lane is configured and proven.
Predicate Schema
See stellaops-policy-verdict.v1.schema.json for the complete JSON schema.
Example Predicate
{
"_type": "https://stellaops.dev/predicates/policy-verdict@v1",
"tenantId": "tenant-alpha",
"policyId": "P-7",
"policyVersion": 4,
"runId": "run:P-7:20251223T140500Z:1b2c3d4e",
"findingId": "finding:sbom:S-42/pkg:npm/lodash@4.17.21",
"evaluatedAt": "2025-12-23T14:06:01+00:00",
"verdict": {
"status": "blocked",
"severity": "critical",
"score": 19.5,
"rationale": "CVE-2025-12345 exploitable via lodash.template with confirmed reachability"
},
"ruleChain": [
{
"ruleId": "rule-allow-known",
"action": "allow",
"decision": "skipped"
},
{
"ruleId": "rule-block-critical",
"action": "block",
"decision": "matched",
"score": 19.5
}
],
"evidence": [
{
"type": "advisory",
"reference": "CVE-2025-12345",
"source": "nvd",
"status": "affected",
"digest": "sha256:abc123...",
"weight": 1.0
},
{
"type": "vex",
"reference": "vex:ghsa-2025-0001",
"source": "vendor",
"status": "not_affected",
"digest": "sha256:def456...",
"weight": 0.5,
"metadata": {}
}
],
"vexImpacts": [
{
"statementId": "vex:ghsa-2025-0001",
"provider": "vendor",
"status": "not_affected",
"accepted": false,
"justification": "VEX statement contradicts confirmed reachability analysis"
}
],
"reachability": {
"status": "confirmed",
"paths": [
{
"entrypoint": "GET /api/users",
"sink": "lodash.template",
"confidence": "high",
"digest": "sha256:path123..."
}
]
},
"metadata": {
"componentPurl": "pkg:npm/lodash@4.17.21",
"sbomId": "sbom:S-42",
"traceId": "01HE0BJX5S4T9YCN6ZT0",
"determinismHash": "sha256:..."
}
}
DSSE Envelope
The predicate is wrapped in a DSSE envelope:
{
"payload": "<base64(canonicalJson(predicate))>",
"payloadType": "application/vnd.stellaops.policy-verdict+json",
"signatures": [
{
"keyid": "sha256:keypair123...",
"sig": "<base64(signature)>"
}
]
}
API Reference
Create Verdict Attestation (Internal)
Note: This is an internal API called by the Policy Engine, not exposed externally.
POST /internal/api/v1/attestations/verdict
Content-Type: application/json
Request:
{
"predicateType": "https://stellaops.dev/predicates/policy-verdict@v1",
"predicate": "{...}",
"subject": [
{
"name": "finding:sbom:S-42/pkg:npm/lodash@4.17.21",
"digest": {
"sha256": "abc123..."
}
}
]
}
Response:
{
"verdictId": "verdict:run:P-7:20251223T140500Z:finding-42",
"attestationUri": "/api/v1/verdicts/verdict:run:P-7:20251223T140500Z:finding-42",
"rekorLogIndex": 12345678
}
Retrieve Verdict Attestation
GET /api/v1/verdicts/{verdictId}
Response:
{
"verdictId": "verdict:run:P-7:20251223T140500Z:finding-42",
"tenantId": "tenant-alpha",
"policyRunId": "run:P-7:20251223T140500Z:1b2c3d4e",
"findingId": "finding:sbom:S-42/pkg:npm/lodash@4.17.21",
"verdictStatus": "blocked",
"evaluatedAt": "2025-12-23T14:06:01+00:00",
"envelope": {
"payload": "<base64>",
"payloadType": "application/vnd.stellaops.policy-verdict+json",
"signatures": [...]
},
"rekorLogIndex": 12345678,
"createdAt": "2025-12-23T14:06:05+00:00"
}
List Verdicts for Policy Run
GET /api/v1/runs/{runId}/verdicts?status=blocked&limit=50
Query Parameters:
status: Filter by verdict status (passed/warned/blocked/quieted)severity: Filter by severity (critical/high/medium/low)limit: Maximum results (default 50, max 200)offset: Pagination offset
Response:
{
"verdicts": [
{
"verdictId": "verdict:run:P-7:20251223T140500Z:finding-42",
"findingId": "finding:sbom:S-42/pkg:npm/lodash@4.17.21",
"verdictStatus": "blocked",
"severity": "critical",
"evaluatedAt": "2025-12-23T14:06:01+00:00"
}
],
"pagination": {
"total": 234,
"limit": 50,
"offset": 0
}
}
Verify Verdict Signature
POST /api/v1/verdicts/{verdictId}/verify
Response:
{
"verdictId": "verdict:run:P-7:20251223T140500Z:finding-42",
"signatureValid": true,
"verifiedAt": "2025-12-23T15:00:00+00:00",
"verifications": [
{
"keyId": "sha256:keypair123...",
"algorithm": "ed25519",
"valid": true
}
],
"rekorVerification": {
"logIndex": 12345678,
"inclusionProofValid": true,
"verifiedAt": "2025-12-23T15:00:01+00:00"
}
}
CLI Usage
Retrieve Verdict
stella verdict get verdict:run:P-7:20251223T140500Z:finding-42
# Output:
# Verdict: blocked (critical, score 19.5)
# Finding: finding:sbom:S-42/pkg:npm/lodash@4.17.21
# Evaluated: 2025-12-23T14:06:01+00:00
# Signature: ✓ Verified (ed25519)
# Rekor: ✓ Anchored (log index 12345678)
Verify Verdict Signature (Offline)
stella verdict verify verdict-12345.json --public-key ./pubkey.pem
# Output:
# ✓ Signature valid
# ✓ Predicate schema valid
# ✓ Determinism hash matches
List Verdicts for Run
stella verdict list --run run:P-7:20251223T140500Z:1b2c3d4e --status blocked
# Output:
# 234 verdicts found (showing 50)
#
# verdict:...:finding-42 | blocked | critical | CVE-2025-12345 | lodash@4.17.21
# verdict:...:finding-78 | blocked | high | CVE-2025-99999 | express@4.18.0
# ...
Download Verdict Envelope
stella verdict download verdict:run:P-7:20251223T140500Z:finding-42 --output ./verdict.json
Implementation Guide
Note: the snippets below illustrate the per-finding batch model and predate the live wiring. The authoritative production integration is the Production wiring section above (
VerdictAttestationCoordinatoron the pack-revision + drift-gate evaluate responses). Treat thePolicyRunExecutionServiceexample as illustrative, not the shipped code path.
Policy Engine Integration
The Policy Engine’s PolicyRunExecutionService calls VerdictAttestationService after evaluating each finding:
public class PolicyRunExecutionService
{
private readonly VerdictAttestationService _verdictAttestationService;
public async Task<PolicyRunStatus> ExecuteAsync(PolicyRunRequest request)
{
// ... policy evaluation logic
foreach (var trace in explainTraces)
{
// Emit verdict attestation
if (_options.VerdictAttestationsEnabled)
{
var verdictId = await _verdictAttestationService.AttestVerdictAsync(trace);
_logger.LogInformation("Verdict attestation created: {VerdictId}", verdictId);
}
}
// ... continue
}
}
Attestor Handler
The Attestor receives attestation requests via internal API:
public class VerdictAttestationHandler
{
public async Task<AttestationResult> HandleAsync(AttestationRequest request)
{
// 1. Validate predicate schema
await _schemaValidator.ValidateAsync(request.Predicate, request.PredicateType);
// 2. Create DSSE envelope
var envelope = await _dsseService.SignAsync(request);
// 3. Store in Evidence Locker
var verdictId = await _evidenceLocker.StoreVerdictAsync(envelope);
// 4. Optional: Anchor in Rekor
long? rekorLogIndex = null;
if (_options.RekorEnabled)
{
rekorLogIndex = await _rekorClient.UploadAsync(envelope);
}
return new AttestationResult
{
VerdictId = verdictId,
RekorLogIndex = rekorLogIndex
};
}
}
Evidence Locker Storage
Verdicts are stored in PostgreSQL with full-text index and object store reference:
INSERT INTO verdict_attestations (
verdict_id,
tenant_id,
run_id,
policy_id,
policy_version,
finding_id,
verdict_status,
evaluated_at,
envelope,
predicate_digest,
rekor_log_index
) VALUES (
'verdict:run:P-7:20251223T140500Z:finding-42',
'tenant-alpha',
'run:P-7:20251223T140500Z:1b2c3d4e',
'P-7',
4,
'finding:sbom:S-42/pkg:npm/lodash@4.17.21',
'blocked',
'2025-12-23T14:06:01+00:00',
'{"payload": "...", "signatures": [...]}',
'sha256:abc123...',
12345678
);
Determinism
Verdict attestations are deterministic when evaluated with identical inputs:
- Input Normalization: Policy inputs (SBOMs, advisories, VEX, environment) are canonicalized
- Canonical JSON: Predicates serialize with lexicographic key ordering
- Stable Sorting: Evidence, rule chains, and arrays are sorted deterministically
- Hash Computation:
determinismHash= SHA256(canonical verdict decision)
The canonical verdict decision includes the predicate type, tenant, policy identity and version, finding identity, verdict status and rationale, rule chain, evidence entries, VEX impacts, reachability details, EWS, budget, policy-bundle metadata, and unknown-detail metadata. It excludes run identifiers and wall-clock evaluation timestamps so replayed equivalent decisions hash identically.
Determinism Validation
# Compute determinism hash for policy run
stella policy determinism-hash run:P-7:20251223T140500Z:1b2c3d4e
# Compare with original run's hash
# If hashes match → deterministic
# If hashes differ → identify divergence source
Offline Support
Verdict attestations support air-gapped deployments:
- Signature Verification: Uses bundled public keys, no network required
- Rekor Optional: Transparency log anchoring is disabled by default in Policy Engine runtime wiring
- Bundle Export: Verdicts included in evidence packs for offline transfer
Runtime Rekor Configuration
Policy Engine treats verdict Rekor anchoring as optional, but the transparency posture must be explicit in production whenever VerdictAttestation:Enabled=true.
| Setting | Required behavior |
|---|---|
VerdictAttestation:TransparencyMode=Offline | Do not submit verdicts to Rekor; export signed evidence for offline verification |
VerdictAttestation:TransparencyMode=OnlineRekor | Rekor anchoring is intended and must use a real Rekor client |
VerdictAttestation:RekorEnabled=true | Requires TransparencyMode=OnlineRekor |
VerdictAttestation:RekorEnabled=false remains the offline default. The built-in UnsupportedVerdictRekorClient is not a mock success path: accidental submission attempts return a failed result, score-gate requests that explicitly ask for Rekor anchoring fail instead of returning an unanchored verdict, and startup rejects VerdictAttestation:RekorEnabled=true while that unsupported client is the only registered implementation.
Future real Rekor support must replace the unsupported client, provide the Rekor endpoint and credentials through configuration/secrets, and add host tests proving submissions are accepted by the real client.
Policy decision signing posture
Policy decision attestations require the Signer service by default. If PolicyDecisionAttestation:UseSignerService=true and no signer client is registered, creation fails closed. If Rekor submission is requested and submission fails, attestation creation fails rather than returning an unanchored success.
PolicyDecisionAttestation:AllowLocalDevelopmentSigning=true is an explicit dev/test-only escape hatch for deterministic local hash signatures. It must not be enabled in production or protected profiles, and local signatures are not a substitute for Signer-backed DSSE verification.
Performance Considerations
Volume
Large policy runs can produce millions of verdicts:
- Batch Signing: Group verdicts into batches, sign batch manifest
- Async Processing: Attestation pipeline runs asynchronously from policy evaluation
- Compression: Verdict envelopes use compact JSON and gzip compression
Storage
- Hot Storage: Recent verdicts (< 30 days) in PostgreSQL
- Cold Storage: Older verdicts archived to object store
- Indexing: Indexes on
run_id,finding_id,tenant_id,evaluated_at
Security
Signing Keys
- Key Management: Keys stored in KMS or CryptoPro (GOST support)
- Key Rotation: Verdicts include
keyId, support multi-signature - Offline Keys: Support for offline signing ceremonies (air-gapped)
- Verifier Requirement: Signature-looking fields are not enough; callers must verify DSSE payload binding against trusted keys and must fail closed when verifier or trust roots are unavailable.
Access Control
- RBAC:
policy:verdict:readscope required for API access - Tenant Isolation: Verdicts scoped by
tenantId, cross-tenant queries blocked - Audit Trail: All verdict retrievals logged with actor, timestamp
Troubleshooting
Verdict Attestation Failed
Symptom: Policy run completes but no verdicts created
Causes:
- Attestor service unavailable → Check health endpoint
- Schema validation failure → Check predicate structure
- Signing key unavailable → Verify KMS connectivity
Resolution:
# Check attestor health
curl http://attestor:8080/health
# Verify predicate schema
stella schema validate policy-verdict.json --schema stellaops-policy-verdict.v1.schema.json
# Retry attestation
stella policy rerun run:P-7:20251223T140500Z:1b2c3d4e --attestations-only
Signature Verification Failed
Symptom: POST /api/v1/verdicts/{id}/verify returns signatureValid: false
Causes:
- Public key mismatch → Verify correct key for
keyId - Payload tampering → Compare digest with original
- Clock skew → Check timestamp validity window
Resolution:
# Verify with correct public key
stella verdict verify verdict.json --public-key ./correct-pubkey.pem
# Check payload digest
stella digest compute verdict.json
