Files
git.stella-ops.org/docs/modules/release-orchestrator/modules/evidence.md

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.json on 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),
  };
}

References