RELEVI: Release Evidence
Purpose: Cryptographically sealed evidence packets for audit-grade release governance.
Modules
Module: evidence-collector
| Aspect | Specification |
|---|---|
| Responsibility | Evidence aggregation; packet composition |
| Dependencies | promotion-manager, deploy-orchestrator, decision-engine |
| Data Entities | EvidencePacket, EvidenceContent |
| Events Produced | evidence.collected, evidence.packet_created |
Evidence Packet Structure:
interface EvidencePacket {
id: UUID;
tenantId: UUID;
promotionId: UUID;
packetType: EvidencePacketType;
content: EvidenceContent;
contentHash: string; // SHA-256 of content
signature: string; // Cryptographic signature
signerKeyRef: string; // Reference to signing key
createdAt: DateTime;
// Note: No updatedAt - packets are immutable
}
type EvidencePacketType =
| "release_decision" // Promotion decision evidence
| "deployment" // Deployment execution evidence
| "rollback" // Rollback evidence
| "ab_promotion"; // A/B promotion evidence
interface EvidenceContent {
// Metadata
version: "1.0";
generatedAt: DateTime;
generatorVersion: string;
// What
release: {
id: UUID;
name: string;
components: Array<{
name: string;
digest: string;
semver: string;
imageRepository: string;
}>;
sourceRef: SourceReference | null;
};
// Where
environment: {
id: UUID;
name: string;
targets: Array<{
id: UUID;
name: string;
type: string;
}>;
};
// Who
actors: {
requester: {
id: UUID;
name: string;
email: string;
};
approvers: Array<{
id: UUID;
name: string;
action: string;
at: DateTime;
comment: string | null;
}>;
};
// Why
decision: {
result: "allow" | "deny";
gates: Array<{
type: string;
name: string;
status: string;
message: string;
details: Record<string, any>;
}>;
reasons: string[];
};
// How
execution: {
workflowRunId: UUID | null;
deploymentJobId: UUID | null;
artifacts: Array<{
type: string;
name: string;
contentHash: string;
}>;
logs: string | null; // Compressed/truncated
};
// When
timeline: {
requestedAt: DateTime;
decidedAt: DateTime | null;
startedAt: DateTime | null;
completedAt: DateTime | null;
};
// Integrity
inputsHash: string; // Hash of all inputs for replay
previousEvidenceId: UUID | null; // Chain to previous evidence
}
Rollback Evidence v1
Runtime rollbacks emitted by the WebApi deployment path are stored as release.rollback.evidence.v1 evidence packets. The payload includes the rollback plan, target digest proof, the failed module that triggered rollback, and deterministic task-order proof for multi-module releases:
proofStatus/proofDigestcover target digest rollback proof.orderProofStatus/orderProofDigestcover original deployment order, rollback order, dependency names, failed module identity, failure reason, and release-script proof digests when those were present on task results.deploymentOrder[]androllbackOrder[]are ordered by task execution time, then batch and target identity. They are stable replay material, not regenerated UI summaries.failureis present only when the original job had a failed task; if it is absent the order proof is incomplete.
The deployment projection repeats the evidence id, stored content hash, proofDigest, proofStatus, orderProofDigest, and orderProofStatus under strategyEvidence.rollback.evidence.* so operator and replay surfaces can jump to the immutable evidence packet.
Module: evidence-signer
| Aspect | Specification |
|---|---|
| Responsibility | Cryptographic signing of evidence packets |
| Dependencies | authority, vault (for key storage) |
| Algorithms | RS256, ES256, Ed25519 |
Signing Process:
class EvidenceSigner {
async sign(content: EvidenceContent): Promise<SignedEvidence> {
// 1. Canonicalize content (RFC 8785)
const canonicalJson = canonicalize(content);
// 2. Compute content hash
const contentHash = crypto
.createHash("sha256")
.update(canonicalJson)
.digest("hex");
// 3. Get signing key from vault
const keyRef = await this.getActiveSigningKey();
const privateKey = await this.vault.getPrivateKey(keyRef);
// 4. Sign the content hash
const signature = await this.signWithKey(contentHash, privateKey);
return {
content,
contentHash: `sha256:${contentHash}`,
signature: base64Encode(signature),
signerKeyRef: keyRef,
algorithm: this.config.signatureAlgorithm,
};
}
async verify(packet: EvidencePacket): Promise<VerificationResult> {
// 1. Canonicalize stored content
const canonicalJson = canonicalize(packet.content);
// 2. Verify content hash
const computedHash = crypto
.createHash("sha256")
.update(canonicalJson)
.digest("hex");
if (`sha256:${computedHash}` !== packet.contentHash) {
return { valid: false, error: "Content hash mismatch" };
}
// 3. Get public key
const publicKey = await this.vault.getPublicKey(packet.signerKeyRef);
// 4. Verify signature
const signatureValid = await this.verifySignature(
computedHash,
base64Decode(packet.signature),
publicKey
);
return {
valid: signatureValid,
signerKeyRef: packet.signerKeyRef,
signedAt: packet.createdAt,
};
}
}
Module: sticker-writer
| Aspect | Specification |
|---|---|
| Responsibility | Version sticker generation and placement |
| Dependencies | deploy-orchestrator, agent-manager |
| Data Entities | VersionSticker |
Version Sticker Schema:
interface VersionSticker {
stella_version: "1.0";
// Release identity
release_id: UUID;
release_name: string;
// Component details
components: Array<{
name: string;
digest: string;
semver: string;
tag: string;
image_repository: string;
}>;
// Deployment context
environment: string;
environment_id: UUID;
deployed_at: string; // ISO 8601
deployed_by: UUID;
// Traceability
promotion_id: UUID;
workflow_run_id: UUID;
// Evidence chain
evidence_packet_id: UUID;
evidence_packet_hash: string;
policy_decision_hash: string;
// Orchestrator info
orchestrator_version: string;
// Source reference
source_ref?: {
commit_sha: string;
branch: string;
repository: string;
};
}
Sticker Placement:
- Written to
/var/stella/version.jsonon each target - Atomic write (write to temp, rename)
- Read during drift detection
- Verified against expected state
Module: audit-exporter
| Aspect | Specification |
|---|---|
| Responsibility | Compliance report generation; evidence export |
| Dependencies | evidence-collector |
| Export Formats | JSON, PDF, CSV |
Audit Report Types:
| Report Type | Description |
|---|---|
release_audit | Full audit trail for a release |
environment_audit | All deployments to an environment |
compliance_summary | Summary for compliance review |
change_log | Chronological change log |
Report Generation:
interface AuditReportRequest {
type: AuditReportType;
scope: {
releaseId?: UUID;
environmentId?: UUID;
from?: DateTime;
to?: DateTime;
};
format: "json" | "pdf" | "csv";
options?: {
includeDecisionDetails: boolean;
includeApproverDetails: boolean;
includeLogs: boolean;
includeArtifacts: boolean;
};
}
interface AuditReport {
id: UUID;
type: AuditReportType;
scope: ReportScope;
generatedAt: DateTime;
generatedBy: UUID;
summary: {
totalPromotions: number;
successfulDeployments: number;
failedDeployments: number;
rollbacks: number;
averageDeploymentTime: number;
};
entries: AuditEntry[];
// For compliance
signatureChain: {
valid: boolean;
verifiedPackets: number;
invalidPackets: number;
};
}
Immutability Enforcement
Evidence packets are append-only. This is enforced at multiple levels:
Runtime Compatibility Contract
The Release Orchestrator WebApi evidence compatibility endpoints verify the hash against stored canonical bytes. Export and raw-download endpoints return those stored bytes (release_orchestrator.release_evidence.content_bytes once repository cutover is complete), not a rebuilt summary assembled from metadata. A packet with tampered stored bytes, missing bytes, an unsupported hash algorithm, or a content hash mismatch is not valid promotion/deploy evidence.
Protected promotion and deployment paths require at least one verified evidence packet for the release. Promotion additionally requires verified policy-decision evidence so missing gate-decision truth fails closed instead of projecting a passing gate from demo data.
Database Level
-- Evidence packets table with no UPDATE/DELETE
CREATE TABLE release.evidence_packets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
promotion_id UUID NOT NULL REFERENCES release.promotions(id),
packet_type VARCHAR(50) NOT NULL CHECK (packet_type IN (
'release_decision', 'deployment', 'rollback', 'ab_promotion'
)),
content JSONB NOT NULL,
content_hash VARCHAR(100) NOT NULL,
signature TEXT,
signer_key_ref VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- Note: No updated_at column; immutable by design
);
-- Append-only enforcement via trigger
CREATE OR REPLACE FUNCTION prevent_evidence_modification()
RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'Evidence packets are immutable and cannot be modified or deleted';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER evidence_packets_immutable
BEFORE UPDATE OR DELETE ON evidence_packets
FOR EACH ROW EXECUTE FUNCTION prevent_evidence_modification();
-- Revoke UPDATE/DELETE from application role
REVOKE UPDATE, DELETE ON release.evidence_packets FROM app_role;
-- Version stickers table
CREATE TABLE release.version_stickers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
target_id UUID NOT NULL REFERENCES release.targets(id),
release_id UUID NOT NULL REFERENCES release.releases(id),
promotion_id UUID NOT NULL REFERENCES release.promotions(id),
sticker_content JSONB NOT NULL,
content_hash VARCHAR(100) NOT NULL,
written_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
verified_at TIMESTAMPTZ,
drift_detected BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_version_stickers_target ON release.version_stickers(target_id);
CREATE INDEX idx_version_stickers_release ON release.version_stickers(release_id);
CREATE INDEX idx_evidence_packets_promotion ON release.evidence_packets(promotion_id);
CREATE INDEX idx_evidence_packets_created ON release.evidence_packets(created_at DESC);
Application Level
// Evidence service enforces immutability
public sealed class EvidenceService
{
// Only Create method - no Update or Delete
public async Task<EvidencePacket> CreateAsync(
EvidenceContent content,
CancellationToken ct)
{
// Sign content
var signed = await _signer.SignAsync(content, ct);
// Store (append-only)
var packet = new EvidencePacket
{
Id = Guid.NewGuid(),
TenantId = content.TenantId,
PromotionId = content.PromotionId,
PacketType = content.PacketType,
Content = content,
ContentHash = signed.ContentHash,
Signature = signed.Signature,
SignerKeyRef = signed.SignerKeyRef,
CreatedAt = DateTime.UtcNow,
};
await _repository.InsertAsync(packet, ct);
return packet;
}
// Read methods only
public async Task<EvidencePacket> GetAsync(Guid id, CancellationToken ct);
public async Task<IReadOnlyList<EvidencePacket>> ListAsync(
EvidenceFilter filter, CancellationToken ct);
public async Task<VerificationResult> VerifyAsync(
Guid id, CancellationToken ct);
// No Update or Delete methods exist
}
Evidence Chain
Evidence packets form a verifiable chain:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Evidence #1 │ │ Evidence #2 │ │ Evidence #3 │
│ (Dev Deploy) │────►│ (Stage Deploy) │────►│ (Prod Deploy) │
│ │ │ │ │ │
│ prevEvidenceId: │ │ prevEvidenceId: │ │ prevEvidenceId: │
│ null │ │ #1 │ │ #2 │
│ │ │ │ │ │
│ contentHash: │ │ contentHash: │ │ contentHash: │
│ sha256:abc... │ │ sha256:def... │ │ sha256:ghi... │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Chain Verification:
async function verifyEvidenceChain(releaseId: UUID): Promise<ChainVerificationResult> {
const packets = await getPacketsForRelease(releaseId);
const results: PacketVerificationResult[] = [];
let previousHash: string | null = null;
for (const packet of packets) {
// 1. Verify packet signature
const signatureValid = await verifySignature(packet);
// 2. Verify content hash
const contentValid = await verifyContentHash(packet);
// 3. Verify chain link
const chainValid = packet.content.previousEvidenceId === null
? previousHash === null
: await verifyPreviousLink(packet, previousHash);
results.push({
packetId: packet.id,
signatureValid,
contentValid,
chainValid,
valid: signatureValid && contentValid && chainValid,
});
previousHash = packet.contentHash;
}
return {
valid: results.every(r => r.valid),
packets: results,
};
}
API Endpoints
# Evidence Packets
GET /api/v1/evidence-packets
Query: ?promotionId={uuid}&type={type}&from={date}&to={date}
Response: EvidencePacket[]
GET /api/v1/evidence-packets/{id}
Response: EvidencePacket (full content)
GET /api/v1/evidence-packets/{id}/verify
Response: VerificationResult
GET /api/v1/evidence-packets/{id}/download
Query: ?format={json|pdf}
Response: binary
# Evidence Chain
GET /api/v1/releases/{id}/evidence-chain
Response: EvidenceChain
GET /api/v1/releases/{id}/evidence-chain/verify
Response: ChainVerificationResult
# Audit Reports
POST /api/v1/audit-reports
Body: {
type: "release" | "environment" | "compliance",
scope: { releaseId?, environmentId?, from?, to? },
format: "json" | "pdf" | "csv"
}
Response: { reportId: UUID, status: "generating" }
GET /api/v1/audit-reports/{id}
Response: { status, downloadUrl? }
GET /api/v1/audit-reports/{id}/download
Response: binary
# Version Stickers
GET /api/v1/version-stickers
Query: ?targetId={uuid}&releaseId={uuid}
Response: VersionSticker[]
GET /api/v1/version-stickers/{id}
Response: VersionSticker
Deterministic Replay
Evidence packets enable deterministic replay - given the same inputs and policy version, the same decision is produced:
async function replayDecision(evidencePacket: EvidencePacket): Promise<ReplayResult> {
const content = evidencePacket.content;
// 1. Verify inputs hash
const currentInputsHash = computeInputsHash(
content.release,
content.environment,
content.decision.gates
);
if (currentInputsHash !== content.inputsHash) {
return { valid: false, error: "Inputs have changed since original decision" };
}
// 2. Re-evaluate decision with same inputs
const replayedDecision = await evaluateDecision(
content.release,
content.environment,
{ asOf: content.timeline.decidedAt } // Use policy version from that time
);
// 3. Compare decisions
const decisionsMatch = replayedDecision.result === content.decision.result;
return {
valid: decisionsMatch,
originalDecision: content.decision.result,
replayedDecision: replayedDecision.result,
differences: decisionsMatch ? [] : computeDifferences(content.decision, replayedDecision),
};
}
