PROMOT: Promotion & Approval Manager
Purpose: Manage promotion requests, approvals, gates, and decision records.
Modules
Module: promotion-manager
| Aspect | Specification |
|---|---|
| Responsibility | Promotion request lifecycle; state management |
| Dependencies | release-manager, environment-manager, workflow-engine |
| Data Entities | Promotion, PromotionState |
| Events Produced | promotion.requested, promotion.approved, promotion.rejected, promotion.started, promotion.completed, promotion.failed, promotion.rolled_back |
Key Operations:
RequestPromotion(releaseId, targetEnvironmentId, reason) → Promotion
ApprovePromotion(promotionId, comment) → Promotion
RejectPromotion(promotionId, reason) → Promotion
CancelPromotion(promotionId) → Promotion
GetPromotionStatus(promotionId) → PromotionState
GetDecisionRecord(promotionId) → DecisionRecord
Promotion Entity:
interface Promotion {
id: UUID;
tenantId: UUID;
releaseId: UUID;
sourceEnvironmentId: UUID | null; // null for first deployment
targetEnvironmentId: UUID;
status: PromotionStatus;
decisionRecord: DecisionRecord;
workflowRunId: UUID | null;
requestedAt: DateTime;
requestedBy: UUID;
requestReason: string;
decidedAt: DateTime | null;
startedAt: DateTime | null;
completedAt: DateTime | null;
evidencePacketId: UUID | null;
}
type PromotionStatus =
| "pending_approval" // Waiting for human approval
| "pending_gate" // Waiting for gate evaluation
| "approved" // Ready for deployment
| "rejected" // Blocked by approval or gate
| "deploying" // Deployment in progress
| "deployed" // Successfully deployed
| "failed" // Deployment failed
| "cancelled" // User cancelled
| "rolled_back"; // Rolled back after failure
Module: approval-gateway
| Aspect | Specification |
|---|---|
| Responsibility | Approval collection; separation of duties enforcement |
| Dependencies | authority (for user/group lookup) |
| Data Entities | Approval, ApprovalPolicy |
| Events Produced | approval.granted, approval.denied |
Approval Policy Entity:
interface ApprovalPolicy {
id: UUID;
tenantId: UUID;
policyId: string; // Stable operator-facing identifier
name: string;
sourceEnvironment?: string; // Null matches any source
targetEnvironment?: string; // Null matches any target
releaseNameGlob?: string; // Null matches any release name
mode: "auto-approve" | "require-manual" | "deny";
requiredApprovals?: number; // Overrides environment default when manual
reason?: string; // Persisted to the gate decision
enabled: boolean;
priority: number; // Higher priority wins before specificity
}
interface Approval {
id: UUID;
tenantId: UUID;
promotionId: UUID;
approverId: UUID;
action: "approved" | "rejected";
comment: string;
approvedAt: DateTime;
approverRole: string;
approverGroups: string[];
}
Separation of Duties (SoD) Rules:
- Requester cannot approve their own promotion (if
requireSeparationOfDutiesis true) - Same user cannot approve twice
- At least N different users must approve (based on
requiredCount) - At least one approver must match
requiredRolesif specified - At least one approver must be in
requiredGroupsif specified
Module: decision-engine
| Aspect | Specification |
|---|---|
| Responsibility | Gate evaluation; policy integration; decision record generation |
| Dependencies | gate-registry, policy (OPA integration), scanner (security data) |
| Data Entities | DecisionRecord, GateResult |
| Events Produced | decision.evaluated, decision.recorded |
Policy ownership note: PASS/FAIL promotion gate semantics are owned by Policy Engine and consumed by promotion workflows. Concelier remains ingestion-only for advisory/linkset data.
Decision Record Structure:
interface DecisionRecord {
promotionId: UUID;
evaluatedAt: DateTime;
decision: "allow" | "deny" | "pending";
// What was evaluated
release: {
id: UUID;
name: string;
components: Array<{
name: string;
digest: string;
semver: string;
}>;
};
environment: {
id: UUID;
name: string;
requiredApprovals: number;
freezeWindow: boolean;
};
// Gate evaluation results
gates: GateResult[];
// Approval status
approvalStatus: {
required: number;
received: number;
approvers: Array<{
userId: UUID;
action: string;
at: DateTime;
}>;
sodViolation: boolean;
};
// Reason for decision
reasons: string[];
// Hash of all inputs for replay verification
inputsHash: string;
}
interface GateResult {
gateType: string;
gateName: string;
status: "passed" | "failed" | "warning" | "skipped";
message: string;
details: Record<string, any>;
evaluatedAt: DateTime;
durationMs: number;
}
Gate Evaluation Order:
- Freeze Window Check: Is environment in freeze?
- Approval Check: All required approvals received?
- Security Gate: No blocking vulnerabilities?
- Custom Policy Gates: All OPA policies pass?
- Integration Gates: External system checks pass?
Module: gate-registry
| Aspect | Specification |
|---|---|
| Responsibility | Built-in + custom gate registration |
| Dependencies | plugin-registry |
| Data Entities | GateDefinition, GateConfig |
Built-in Gates:
| Gate Type | Description |
|---|---|
freeze-window | Check if environment is in freeze |
approval | Check if required approvals received |
security-scan | Check for blocking vulnerabilities |
scan-freshness | Check if scan is recent enough |
digest-verification | Verify digests haven’t changed |
environment-sequence | Enforce promotion order |
custom-opa | Custom OPA/Rego policy |
webhook | External webhook gate |
Gate Definition:
interface GateDefinition {
type: string;
displayName: string;
description: string;
configSchema: JSONSchema;
evaluator: "builtin" | UUID; // builtin or plugin ID
blocking: boolean; // Can block promotion
cacheable: boolean; // Can cache result
cacheTtlSeconds: number;
}
Promotion State Machine
┌─────────────────────────────────────────────────────────────────────────────┐
│ PROMOTION STATE MACHINE │
│ │
│ ┌───────────────┐ │
│ │ REQUESTED │ ◄──── User requests promotion │
│ └───────┬───────┘ │
│ │ │
│ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ PENDING │─────►│ REJECTED │ ◄──── Approver rejects │
│ │ APPROVAL │ └───────────────┘ │
│ └───────┬───────┘ │
│ │ approval received │
│ ▼ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ PENDING │─────►│ REJECTED │ ◄──── Gate fails │
│ │ GATE │ └───────────────┘ │
│ └───────┬───────┘ │
│ │ all gates pass │
│ ▼ │
│ ┌───────────────┐ │
│ │ APPROVED │ ◄──── Ready for deployment │
│ └───────┬───────┘ │
│ │ workflow starts │
│ ▼ │
│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │
│ │ DEPLOYING │─────►│ FAILED │─────►│ ROLLED_BACK │ │
│ └───────┬───────┘ └───────────────┘ └───────────────┘ │
│ │ │
│ │ deployment complete │
│ ▼ │
│ ┌───────────────┐ │
│ │ DEPLOYED │ ◄──── Success! │
│ └───────────────┘ │
│ │
│ Additional transitions: │
│ - Any non-terminal → CANCELLED: user cancels │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Database Schema
-- Promotions
CREATE TABLE release.promotions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
release_id UUID NOT NULL REFERENCES release.releases(id),
source_environment_id UUID REFERENCES release.environments(id),
target_environment_id UUID NOT NULL REFERENCES release.environments(id),
status VARCHAR(50) NOT NULL DEFAULT 'pending_approval' CHECK (status IN (
'pending_approval', 'pending_gate', 'approved', 'rejected',
'deploying', 'deployed', 'failed', 'cancelled', 'rolled_back'
)),
decision_record JSONB,
workflow_run_id UUID REFERENCES release.workflow_runs(id),
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
requested_by UUID NOT NULL REFERENCES users(id),
request_reason TEXT,
decided_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
evidence_packet_id UUID
);
CREATE INDEX idx_promotions_tenant ON release.promotions(tenant_id);
CREATE INDEX idx_promotions_release ON release.promotions(release_id);
CREATE INDEX idx_promotions_status ON release.promotions(status);
CREATE INDEX idx_promotions_target_env ON release.promotions(target_environment_id);
-- Approvals
CREATE TABLE release.approvals (
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) ON DELETE CASCADE,
approver_id UUID NOT NULL REFERENCES users(id),
action VARCHAR(50) NOT NULL CHECK (action IN ('approved', 'rejected')),
comment TEXT,
approved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
approver_role VARCHAR(255),
approver_groups JSONB NOT NULL DEFAULT '[]'
);
CREATE INDEX idx_approvals_promotion ON release.approvals(promotion_id);
CREATE INDEX idx_approvals_approver ON release.approvals(approver_id);
-- Approval Policies
CREATE TABLE release_orchestrator.approval_policies (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
policy_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
source_environment TEXT,
target_environment TEXT,
release_name_glob TEXT,
mode TEXT NOT NULL CHECK (mode IN ('auto-approve', 'require-manual', 'deny')),
required_approvals INTEGER CHECK (required_approvals IS NULL OR required_approvals >= 0),
reason TEXT,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
priority INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
created_by TEXT NOT NULL,
updated_by TEXT NOT NULL,
UNIQUE (tenant_id, policy_id)
);
API Endpoints
# Promotions
POST /api/v1/promotions
Body: { releaseId, targetEnvironmentId, reason? }
Response: Promotion
GET /api/v1/promotions
Query: ?status={status}&releaseId={uuid}&environmentId={uuid}&page={n}
Response: { data: Promotion[], meta: PaginationMeta }
GET /api/v1/promotions/{id}
Response: Promotion (with decision record, approvals)
POST /api/v1/promotions/{id}/approve
Body: { comment? }
Response: Promotion
POST /api/v1/promotions/{id}/reject
Body: { reason }
Response: Promotion
POST /api/v1/promotions/{id}/cancel
Response: Promotion
GET /api/v1/promotions/{id}/decision
Response: DecisionRecord
GET /api/v1/promotions/{id}/approvals
Response: Approval[]
GET /api/v1/promotions/{id}/evidence
Response: EvidencePacket
# Gate Evaluation Preview
POST /api/v1/promotions/preview-gates
Body: { releaseId, targetEnvironmentId }
Response: { wouldPass: boolean, gates: GateResult[] }
Simulation boundary: preview and bundle simulation responses are advisory diagnostics only. They are not promotion decision records and must not be used as deploy approval evidence. If the bundle simulator cannot hydrate real promotion artifacts, it returns `Error`, `AllGatesPassed=false`, and the blocking gate `promotion_artifact_hydration_unavailable`.
# Approval Policies
POST /api/v1/release-orchestrator/approval-policies
GET /api/v1/release-orchestrator/approval-policies
GET /api/v1/release-orchestrator/approval-policies/{id}
PUT /api/v1/release-orchestrator/approval-policies/{id}
DELETE /api/v1/release-orchestrator/approval-policies/{id}
# Pending Approvals (for current user)
GET /api/v1/my/pending-approvals
Response: Promotion[]
Security Gate Integration
The security gate evaluates the release against vulnerability data from the Scanner module:
interface SecurityGateConfig {
blockOnCritical: boolean; // Block if any critical severity
blockOnHigh: boolean; // Block if any high severity
maxCritical: number; // Max allowed critical (0 for strict)
maxHigh: number; // Max allowed high
requireFreshScan: boolean; // Require scan within N hours
scanFreshnessHours: number; // How recent scan must be
allowExceptions: boolean; // Allow VEX exceptions
requireVexJustification: boolean; // Require VEX for exceptions
requireEvidenceScoreMatch: boolean; // Require Evidence Locker score match
}
interface SecurityGateResult {
passed: boolean;
summary: {
critical: number;
high: number;
medium: number;
low: number;
};
blocking: Array<{
cve: string;
severity: string;
component: string;
digest: string;
fixAvailable: boolean;
}>;
exceptions: Array<{
cve: string;
vexStatus: string;
justification: string;
}>;
scanAge: {
component: string;
scannedAt: DateTime;
ageHours: number;
fresh: boolean;
}[];
}
When requireEvidenceScoreMatch=true, the security gate enforces fail-closed Evidence Locker checks per component:
- recompute expected
evidence_scorefrom reproducibility inputs (canonical_bom_sha256,payload_digest, sortedattestation_refs) - query Evidence Locker by
artifact_id - require
status=ready - require exact score equality
Violation codes emitted for this flow:
SEC_REPRO_EVIDENCE_ARTIFACT_MISSINGSEC_REPRO_EVIDENCE_SCORE_INPUT_INVALIDSEC_REPRO_EVIDENCE_SCORE_REFS_INVALIDSEC_REPRO_EVIDENCE_SCORE_MISSINGSEC_REPRO_EVIDENCE_SCORE_NOT_READYSEC_REPRO_EVIDENCE_SCORE_MISMATCH
