Evidence Packet Schema
Overview
Evidence packets are cryptographically signed, immutable records of deployment decisions and outcomes. They provide audit-grade proof of who did what, when, and why.
The
EvidencePacketmodel below is the design-target rich evidence record. The as-built runtime truth-store contract actually served byEvidenceEndpoints.csover therelease_orchestrator.release_evidencetable is the flatterEvidencePacketDtodocumented in the next section — that is the authoritative shape the promote/deploy truth guards consult.
Canonical subject and evidence-spine contract (as-built, 2026-07-13)
This section is the cross-module identity and handoff contract for the release decision trace. A tenant is authorization context, not part of a subject and never a substitute for one of the identifiers below.
Subject identities
| Subject | Canonical identity | Binding and ownership |
|---|---|---|
| Artifact / image | Lowercase OCI digest sha256:<64 lowercase hex>. | Release Orchestrator validates and persists the digest. A mutable tag, semantic version, release id, evidence hash, policy-pack digest, or Merkle root is not an artifact identity. |
| Release | releaseId; version remains display/change context. | Release Orchestrator owns the aggregate. Components add componentId plus their exact artifact digest. |
| Approval | approvalId. | The approval is bound to releaseId, target environment, candidate digest, and component subjects. Quorum state alone is not a gate decision. |
| Gate decision | decisionId. | The durable decision is bound to approvalId, releaseId, target environment, decision-basis hash, envelope, key id, and verified policy-decision evidence. |
| Deployment | deploymentId. | The deployment projection repeats approvalId, releaseId, environmentId, candidate digest, and component subjects. A target id identifies only a deployment subtask. |
| Release evidence packet | evidenceId; releaseId is its aggregate join. | Release Orchestrator owns the canonical bytes, content hash, signature reference, and verification state. The content hash addresses the packet, not the deployed artifact. |
| Artifact Canonical Record | EvidenceLocker canonicalId. | GET /api/v1/evidence/thread/{canonicalId} returns the record’s artifact_digest, PURL, and attestations. canonicalId locates the EvidenceLocker record; it does not replace the OCI digest. |
| Attestation receipt | Attestor uuid; the in-toto subject is name plus its digest map. | GET /api/v1/attestations?subject=... finds receipts for a digest/PURL subject. The receipt id identifies the logged record; verification must still prove subject binding and trust. |
| Observed estate row | environmentId + componentId, carrying digest, releaseId, and deploymentId. | Release Orchestrator Estate owns current observed state. Release events, gate decisions, and audit remain the history authority. |
Legacy release rows that cannot yield one exact artifact subject return legacy_release_digest_missing, legacy_release_digest_invalid, or release_digest_ambiguous. Registry/read-model currency failures keep separate reasons such as current_digest_missing or current_digest_invalid; consumers must not collapse an unavailable producer into a claim that evidence never existed.
Web route and query handoff
New links use one subject passport:
/evidence/custody?subject=<URL-encoded canonical subject>
Prefer the exact OCI digest. A PURL is valid when the package is the intended subject. A release name is a compatibility fallback for a legacy/no-digest row and may require the operator to choose one component. Chain of Custody still accepts the typed aliases digest, purl, and release for old links, but new producers must write subject.
The Web resolver preserves continuity across the three operator entry forms: an exact release name is resolved by the server-side release search, a canonical releaseId is read directly, and an OCI digest is joined through matching deployment/approval truth before the same release component list is loaded. The selected component digest remains the artifact subjectId; releaseId remains the aggregate identity. Component order is deterministic, and a digest match selects that exact component rather than silently falling back to the first component.
Owner records use path identifiers rather than overloading subject:
| Owner surface | Parameter contract |
|---|---|
| Release detail | /releases/detail/:releaseId |
| Approval decision cockpit | /releases/approvals/:approvalId |
| Deployment monitor | /releases/deployments/:deploymentId |
| Release evidence API | /api/v1/release-orchestrator/evidence/:evidenceId; list join is ?releaseId=:releaseId |
| EvidenceLocker thread | /api/v1/evidence/thread/:canonicalId; PURL lookup is ?purl=... |
| Attestor receipt list | /api/v1/attestations?subject=<digest-or-purl> |
Verify handoffs use /evidence/verify?packId=<id>&version=<integer>&subject=<value>; digest is a compatibility alias only. Release/promotion launchers pass releaseId, optional environmentId, and a validated local returnTo. Shared scope/view keys follow the UI context-route contract. Do not pass a package-ref id, version, release name, evidence hash, pack digest, or Merkle root as if it were an OCI digest.
Source to Watch evidence spine
The requested Source → Build → Scan → Decision → Deploy → Watch lifecycle has an explicit Verdict phase between Scan and Decision in the implemented Chain of Custody view:
| Phase | Producer and durable subject | Consumer / continuity rule |
|---|---|---|
| Source | SCM/build integration identifies the component and source provenance. Web reads the selected component’s sourceRepoUrl, sourceCommitSha, pipelineRef, and provenanceAttestationRef from Release Orchestrator truth. | Build carries the same component identity forward; source labels never replace the image digest. Missing lineage directs recovery to the SCM/build integration or provenance re-ingest; ambiguous lineage remains unavailable until component-version truth is reconciled. |
| Build | Build/SBOM attestation names the artifact and exact digest. | Scan queries and attestations use that digest. |
| Scan | Scanner emits scan/SBOM evidence for the digest. | Verdict evaluation consumes the same digest and records its inputs. |
| Verdict | Policy/verdict attestation binds outcome, policy basis, and subject digest. | Release gate decision records the verdict reference and basis; a receipt without verified binding is not proof. |
| Decision | Release Orchestrator persists approval, gate decision, basis, component subjects, and verified decision evidence. | Deploy re-runs the truth guard against the same release, approval, target, digest, basis, and evidence. WAITING, empty, advisory, or skipped gate truth cannot become an approval. |
| Deploy | Deployment projection and events repeat release, approval, environment, candidate digest, and component subjects. | Target execution pulls by digest; completion alone does not prove landed state. |
| Watch | Estate records observed component digest by environment and deployment. | Drift/landing comparison closes the trace; unobserved remains explicit rather than being treated as success. |
State namespaces and recovery
These namespaces are intentionally different. UI copy must not translate one namespace into a stronger claim from another.
| Namespace | Values | Meaning |
|---|---|---|
| Global Signing posture | checking, signed, unsigned, unknown | Tenant signing-key readiness from active key inventory. signed means a signing capability is available; it says nothing about any evidence record. |
| Web release aggregate posture | missing, partial, verified, verification_failed, replay_mismatch, unknown | Roll-up across the release’s evidence/verification results. partial is an aggregate mixture, not a packet status. |
| Release evidence packet | recorded, verified, verification_failed | Runtime release_evidence truth defined below. Only the verification service writes verified or verification_failed. |
| Record-level UI badge | missing, unknown, recorded, signed, proven, tampered | signed means signature material exists but is not proven; proven requires successful verification; tampered requires a failed verification result. |
| Operational availability | unavailable, unknown, unobserved, or a typed owner error | The owner could not be read or no observation exists. This is not evidence absence and must not be promoted to success. |
Current Web compatibility debt: getEvidencePostureLabel renders the aggregate verified value as Recorded. That label understates the aggregate and is not a semantic conversion: it must not be used to turn a packet’s recorded state into verified, or to construct a record-level Proven badge without a real verification result.
| Observed state | Owner / recovery action |
|---|---|
missing | Ask the phase producer to create or re-ingest the absent record; for legacy releases inspect the typed digest reason. Never synthesize an id or digest. |
unavailable / unknown | Check owner health, authorization, and currency, then retry that read. Preserve successful sibling results. |
unsigned / recorded / signed | Configure approved key/trust material or obtain the required signature, then run verification. Protected paths remain blocked where verified evidence is required. |
verified / proven | Preserve the subject, signer/key, basis, and verification checks; no recovery is implied. |
verification_failed / tampered / replay_mismatch | Quarantine the claim, compare stored bytes and subject/basis binding, regenerate only from the owning producer, then re-verify. Do not blindly retry approval/deploy. |
partial | Inspect each child record and recover only missing, unavailable, or failed children; do not discard already verified evidence. |
unobserved | Restore the Watch signal or agent observation and compare the landed digest before declaring deployment success. |
Runtime release-evidence status model (as-built, Sprint 20260704_002)
The runtime evidence packet (EvidenceEndpoints.EvidencePacketDto, table release_orchestrator.release_evidence) carries a statusthat is now honest about what has actually been proven (EVH-1/EVH-2). Before this sprint every packet was written verified at POST time from a content self-hash only — a self-asserted claim, never a signature verification. The vocabulary (EvidenceStatuses):
status | Meaning | Set by |
|---|---|---|
recorded | Stored and its canonical bytes re-hash to the recorded content hash (integrity precondition). Not authenticated. This is the landing state for every POST /evidence. | POST ingest (EVH-1) |
verified | A real verification step validated a DSSE signature over the packet against a configured trust anchor and bound the signed payload to the stored content. | EvidenceVerificationService only (EVH-2) |
verification_failed | A verification step ran against present signature material and did not pass (bad signature, broken payload binding, or tampered content). | EvidenceVerificationService only (EVH-2) |
Verification (EVH-2) — IEvidenceVerificationService, backed by the Attestor DSSE verification engine (StellaOps.Attestation.IDsseVerifier). Ordered checks, each persisted on the packet (verificationChecks[], verificationReason, verifiedKeyId):
content_integrity— stored bytes re-hash tocontent_hash(elseverification_failed).signature_present— a DSSE envelope is found inline insignatureRef(raw or base64 JSON) or the canonical content is itself a DSSE envelope. If absent, the packet staysrecorded(no_signature_material).trust_anchor_configured— at least one PEM public key is configured underReleaseOrchestrator:EvidenceVerification:TrustedPublicKeys. If none, the packet staysrecorded(no_trust_anchor) — honest “unable to verify”, never a falseverified.signature_valid— at least one signature validates under a trusted key.payload_binding— the signed payload binds to this content: eithersha256(payload) == content_hash(the envelope signs the content directly) or the payload is an in-toto Statement whosesubject[].digest.sha256equals the content hash’s hex.
All pass ⇒ verified; any of 2–5 fail while an envelope is present ⇒ verification_failed; missing envelope / trust anchor ⇒ recorded.
Promotion policy-decision packets follow the same rule. Their canonical content is the exact gate-decision+basis payload carried by the DSSE envelope, and the envelope is stored in signatureRef. A missing basis, unsigned decision, absent trust anchor, or failed verification therefore remains recorded/verification_failed; PromotionRequestProcessor never promotes a self-hash to verified.
Truth-guard enforcement (EVH-3) — ReleaseTruthGuard’s ≥1-verified requirement is governed by ReleaseOrchestrator:EvidenceVerification and mirrors the reachability-gate rollout:
- Advisory (default) — a content-intact
recordedpacket satisfies the promote/deploy evidence check; a warning is logged when no genuinely-verifiedpacket exists. Averification_failedpacket never satisfies the check, in any mode. - Blocking —
RequireVerifiedEvidence: true(blanket) or the target environment inBlockingEnvironmentsrequires a genuinely EVH-2-verifiedpacket; a recorded-only release is refused withevidence_unverified.
The console evidence chip (DTC-4 read-model) derives posture from the joined evidenceCount / verifiedEvidenceCount / failedEvidenceCount: 0 → missing, any failed → verification_failed, all verified → verified, otherwise partial (recorded but not verified).
Evidence Packet Types
| Type | Description | Generated When |
|---|---|---|
release_decision | Promotion decision evidence | Promotion approved/rejected |
deployment | Deployment execution evidence | Deployment completes |
rollback | Rollback evidence | Rollback completes |
ab_promotion | A/B release promotion evidence | A/B promotion completes |
Schema Definition
Evidence Packet Structure
interface EvidencePacket {
// Identification
id: UUID;
version: "1.0";
type: EvidencePacketType;
// Metadata
generatedAt: DateTime;
generatorVersion: string;
tenantId: UUID;
// Content
content: EvidenceContent;
// Integrity
contentHash: string; // SHA-256 of canonical JSON content
signature: string; // Base64-encoded signature
signatureAlgorithm: string; // "RS256", "ES256"
signerKeyRef: string; // Reference to signing key
}
type EvidencePacketType =
| "release_decision"
| "deployment"
| "rollback"
| "ab_promotion";
Evidence Content
interface EvidenceContent {
// What was released
release: ReleaseEvidence;
// Where it was released
environment: EnvironmentEvidence;
// Who requested and approved
actors: ActorEvidence;
// Why it was allowed
decision: DecisionEvidence;
// How it was executed (deployment only)
execution?: ExecutionEvidence;
// Previous state (for rollback)
previous?: PreviousStateEvidence;
}
Release Evidence
interface ReleaseEvidence {
id: UUID;
name: string;
displayName: string;
createdAt: DateTime;
createdBy: ActorRef;
components: Array<{
id: UUID;
name: string;
digest: string;
semver: string;
tag: string;
role: "primary" | "sidecar" | "init" | "migration";
}>;
sourceRef?: {
scmIntegrationId?: UUID;
repository?: string;
commitSha?: string;
branch?: string;
ciIntegrationId?: UUID;
buildId?: string;
pipelineUrl?: string;
};
}
Environment Evidence
interface EnvironmentEvidence {
id: UUID;
name: string;
displayName: string;
orderIndex: number;
targets: Array<{
id: UUID;
name: string;
type: string;
healthStatus: string;
}>;
configuration: {
requiredApprovals: number;
requireSeparationOfDuties: boolean;
promotionPolicy?: string;
deploymentTimeout: number;
};
}
Actor Evidence
interface ActorEvidence {
requester: ActorRef;
requestReason: string;
requestedAt: DateTime;
approvers: Array<{
actor: ActorRef;
action: "approved" | "rejected";
comment?: string;
timestamp: DateTime;
roles: string[];
}>;
deployer?: {
agent: AgentRef;
triggeredBy: ActorRef;
startedAt: DateTime;
};
}
interface ActorRef {
id: UUID;
type: "user" | "system" | "agent";
name: string;
email?: string;
}
interface AgentRef {
id: UUID;
name: string;
version: string;
}
Decision Evidence
interface DecisionEvidence {
promotionId: UUID;
decision: "allow" | "block";
decidedAt: DateTime;
gateResults: Array<{
gateName: string;
gateType: string;
passed: boolean;
blocking: boolean;
message: string;
evaluatedAt: DateTime;
details: object;
}>;
freezeWindowCheck: {
checked: boolean;
windowActive: boolean;
windowId?: UUID;
exemption?: {
grantedBy: UUID;
reason: string;
};
};
separationOfDuties: {
required: boolean;
satisfied: boolean;
requesterIds: UUID[];
approverIds: UUID[];
};
}
Execution Evidence
interface ExecutionEvidence {
deploymentJobId: UUID;
strategy: string;
startedAt: DateTime;
completedAt: DateTime;
status: "succeeded" | "failed" | "rolled_back";
tasks: Array<{
targetId: UUID;
targetName: string;
agentId: UUID;
status: string;
startedAt: DateTime;
completedAt: DateTime;
digest: string;
stickerWritten: boolean;
error?: string;
}>;
artifacts: Array<{
name: string;
type: string;
sha256: string;
storageRef: string;
}>;
metrics: {
totalTasks: number;
succeededTasks: number;
failedTasks: number;
totalDurationSeconds: number;
};
}
Previous State Evidence
interface PreviousStateEvidence {
releaseId: UUID;
releaseName: string;
deployedAt: DateTime;
deployedBy: ActorRef;
components: Array<{
name: string;
digest: string;
}>;
}
Example Evidence Packet
{
"id": "evid-12345-uuid",
"version": "1.0",
"type": "deployment",
"generatedAt": "2026-01-10T14:35:00Z",
"generatorVersion": "stella-evidence-generator@1.5.0",
"tenantId": "tenant-uuid",
"content": {
"release": {
"id": "rel-uuid",
"name": "myapp-v2.3.1",
"displayName": "MyApp v2.3.1",
"createdAt": "2026-01-10T10:00:00Z",
"createdBy": {
"id": "user-uuid",
"type": "user",
"name": "John Doe",
"email": "john@example.com"
},
"components": [
{
"id": "comp-api-uuid",
"name": "api",
"digest": "sha256:abc123def456...",
"semver": "2.3.1",
"tag": "v2.3.1",
"role": "primary"
},
{
"id": "comp-worker-uuid",
"name": "worker",
"digest": "sha256:789xyz...",
"semver": "2.3.1",
"tag": "v2.3.1",
"role": "primary"
}
],
"sourceRef": {
"repository": "github.com/myorg/myapp",
"commitSha": "abc123",
"branch": "main",
"buildId": "build-456"
}
},
"environment": {
"id": "env-prod-uuid",
"name": "production",
"displayName": "Production",
"orderIndex": 2,
"targets": [
{
"id": "target-1-uuid",
"name": "prod-web-01",
"type": "compose_host",
"healthStatus": "healthy"
},
{
"id": "target-2-uuid",
"name": "prod-web-02",
"type": "compose_host",
"healthStatus": "healthy"
}
],
"configuration": {
"requiredApprovals": 2,
"requireSeparationOfDuties": true,
"deploymentTimeout": 600
}
},
"actors": {
"requester": {
"id": "user-john-uuid",
"type": "user",
"name": "John Doe",
"email": "john@example.com"
},
"requestReason": "Release v2.3.1 with performance improvements",
"requestedAt": "2026-01-10T12:00:00Z",
"approvers": [
{
"actor": {
"id": "user-jane-uuid",
"type": "user",
"name": "Jane Smith",
"email": "jane@example.com"
},
"action": "approved",
"comment": "LGTM, tests passed",
"timestamp": "2026-01-10T13:00:00Z",
"roles": ["release_manager"]
},
{
"actor": {
"id": "user-bob-uuid",
"type": "user",
"name": "Bob Johnson",
"email": "bob@example.com"
},
"action": "approved",
"comment": "Approved for production",
"timestamp": "2026-01-10T13:30:00Z",
"roles": ["approver"]
}
],
"deployer": {
"agent": {
"id": "agent-prod-uuid",
"name": "prod-agent-01",
"version": "1.5.0"
},
"triggeredBy": {
"id": "system",
"type": "system",
"name": "Stella Orchestrator"
},
"startedAt": "2026-01-10T14:00:00Z"
}
},
"decision": {
"promotionId": "promo-uuid",
"decision": "allow",
"decidedAt": "2026-01-10T13:55:00Z",
"gateResults": [
{
"gateName": "security-gate",
"gateType": "security",
"passed": true,
"blocking": true,
"message": "No critical or high vulnerabilities",
"evaluatedAt": "2026-01-10T13:50:00Z",
"details": {
"critical": 0,
"high": 0,
"medium": 5,
"low": 12
}
},
{
"gateName": "approval-gate",
"gateType": "approval",
"passed": true,
"blocking": true,
"message": "2/2 required approvals received",
"evaluatedAt": "2026-01-10T13:55:00Z",
"details": {
"required": 2,
"received": 2
}
}
],
"freezeWindowCheck": {
"checked": true,
"windowActive": false
},
"separationOfDuties": {
"required": true,
"satisfied": true,
"requesterIds": ["user-john-uuid"],
"approverIds": ["user-jane-uuid", "user-bob-uuid"]
}
},
"execution": {
"deploymentJobId": "job-uuid",
"strategy": "rolling",
"startedAt": "2026-01-10T14:00:00Z",
"completedAt": "2026-01-10T14:35:00Z",
"status": "succeeded",
"tasks": [
{
"targetId": "target-1-uuid",
"targetName": "prod-web-01",
"agentId": "agent-prod-uuid",
"status": "succeeded",
"startedAt": "2026-01-10T14:00:00Z",
"completedAt": "2026-01-10T14:15:00Z",
"digest": "sha256:abc123def456...",
"stickerWritten": true
},
{
"targetId": "target-2-uuid",
"targetName": "prod-web-02",
"agentId": "agent-prod-uuid",
"status": "succeeded",
"startedAt": "2026-01-10T14:20:00Z",
"completedAt": "2026-01-10T14:35:00Z",
"digest": "sha256:abc123def456...",
"stickerWritten": true
}
],
"artifacts": [
{
"name": "compose.stella.lock.yml",
"type": "compose-lock",
"sha256": "checksum...",
"storageRef": "s3://artifacts/job-uuid/compose.stella.lock.yml"
}
],
"metrics": {
"totalTasks": 2,
"succeededTasks": 2,
"failedTasks": 0,
"totalDurationSeconds": 2100
}
}
},
"contentHash": "sha256:content-hash...",
"signature": "base64-signature...",
"signatureAlgorithm": "RS256",
"signerKeyRef": "stella/signing/prod-key-2026"
}
Signature Verification
async function verifyEvidencePacket(packet: EvidencePacket): Promise<VerificationResult> {
// 1. Verify content hash
const canonicalContent = canonicalize(packet.content);
const computedHash = sha256(canonicalContent);
if (computedHash !== packet.contentHash) {
return { valid: false, error: "Content hash mismatch" };
}
// 2. Get signing key
const publicKey = await getPublicKey(packet.signerKeyRef);
// 3. Verify signature
const signatureValid = await verify(
packet.signature,
packet.contentHash,
publicKey,
packet.signatureAlgorithm
);
if (!signatureValid) {
return { valid: false, error: "Invalid signature" };
}
return { valid: true };
}
Storage
Evidence packets are stored in an append-only table:
CREATE TABLE release.evidence_packets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
promotion_id UUID NOT NULL REFERENCES release.promotions(id),
type TEXT NOT NULL,
version TEXT NOT NULL DEFAULT '1.0',
content JSONB NOT NULL,
content_hash TEXT NOT NULL,
signature TEXT NOT NULL,
signature_algorithm TEXT NOT NULL,
signer_key_ref TEXT NOT NULL,
generated_at TIMESTAMPTZ NOT NULL,
generator_version TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- Note: No updated_at - packets are immutable
);
-- Prevent modifications
REVOKE UPDATE, DELETE ON release.evidence_packets FROM app_role;
Export Formats
Evidence packets can be exported in multiple formats:
| Format | Use Case |
|---|---|
| JSON | API consumption, archival |
| SignedJSON | DSSE-signed JSON for verification workflows |
| Markdown | Human-readable documentation |
| HTML | Styled web reports |
| Human-readable compliance reports | |
| CSV | Spreadsheet analysis |
| SLSA | SLSA provenance format |
| EvidenceCard | Single-file evidence card with SBOM excerpt, DSSE envelope, and Rekor receipt (v1.1) |
| EvidenceCardCompact | Compact evidence card without full SBOM (v1.1) |
Evidence Card Format (v1.1)
The evidence-card format packages related artifacts into a single JSON file for offline verification:
- SBOM Excerpt: Relevant component information from the full SBOM
- DSSE Envelope: Dead Simple Signing Envelope containing the signed payload
- Rekor Receipt: Optional Sigstore Rekor transparency log receipt for audit trail
Content type: application/vnd.stellaops.evidence-card+json
See Evidence Decision API for schema details.
