RELEVI: Release Evidence

Purpose: Cryptographically sealed evidence packets for audit-grade release governance.

Modules

Module: evidence-collector

AspectSpecification
ResponsibilityEvidence aggregation; packet composition
Dependenciespromotion-manager, deploy-orchestrator, decision-engine
Data EntitiesEvidencePacket, EvidenceContent
Events Producedevidence.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:

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

AspectSpecification
ResponsibilityCryptographic signing of evidence packets
Dependenciesauthority, vault (for key storage)
AlgorithmsRS256, 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

AspectSpecification
ResponsibilityVersion sticker generation and placement
Dependenciesdeploy-orchestrator, agent-manager
Data EntitiesVersionSticker

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:


Module: audit-exporter

AspectSpecification
ResponsibilityCompliance report generation; evidence export
Dependenciesevidence-collector
Export FormatsJSON, PDF, CSV

Audit Report Types:

Report TypeDescription
release_auditFull audit trail for a release
environment_auditAll deployments to an environment
compliance_summarySummary for compliance review
change_logChronological 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),
  };
}

References