15 KiB
15 KiB
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
}
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:
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),
};
}