release orchestrator pivot, architecture and planning
This commit is contained in:
575
docs/modules/release-orchestrator/modules/evidence.md
Normal file
575
docs/modules/release-orchestrator/modules/evidence.md
Normal file
@@ -0,0 +1,575 @@
|
||||
# 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**:
|
||||
```typescript
|
||||
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**:
|
||||
```typescript
|
||||
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**:
|
||||
```typescript
|
||||
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**:
|
||||
```typescript
|
||||
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
|
||||
```sql
|
||||
-- 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
|
||||
```csharp
|
||||
// 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**:
|
||||
```typescript
|
||||
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
|
||||
|
||||
```yaml
|
||||
# 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:
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
- [Module Overview](overview.md)
|
||||
- [Design Principles](../design/principles.md)
|
||||
- [Security Architecture](../security/overview.md)
|
||||
- [Evidence Schema](../appendices/evidence-schema.md)
|
||||
Reference in New Issue
Block a user