PROMOT: Promotion & Approval Manager

Purpose: Manage promotion requests, approvals, gates, and decision records.

Modules

Module: promotion-manager

AspectSpecification
ResponsibilityPromotion request lifecycle; state management
Dependenciesrelease-manager, environment-manager, workflow-engine
Data EntitiesPromotion, PromotionState
Events Producedpromotion.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

AspectSpecification
ResponsibilityApproval collection; separation of duties enforcement
Dependenciesauthority (for user/group lookup)
Data EntitiesApproval, ApprovalPolicy
Events Producedapproval.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:

  1. Requester cannot approve their own promotion (if requireSeparationOfDuties is true)
  2. Same user cannot approve twice
  3. At least N different users must approve (based on requiredCount)
  4. At least one approver must match requiredRoles if specified
  5. At least one approver must be in requiredGroups if specified

Module: decision-engine

AspectSpecification
ResponsibilityGate evaluation; policy integration; decision record generation
Dependenciesgate-registry, policy (OPA integration), scanner (security data)
Data EntitiesDecisionRecord, GateResult
Events Produceddecision.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:

  1. Freeze Window Check: Is environment in freeze?
  2. Approval Check: All required approvals received?
  3. Security Gate: No blocking vulnerabilities?
  4. Custom Policy Gates: All OPA policies pass?
  5. Integration Gates: External system checks pass?

Module: gate-registry

AspectSpecification
ResponsibilityBuilt-in + custom gate registration
Dependenciesplugin-registry
Data EntitiesGateDefinition, GateConfig

Built-in Gates:

Gate TypeDescription
freeze-windowCheck if environment is in freeze
approvalCheck if required approvals received
security-scanCheck for blocking vulnerabilities
scan-freshnessCheck if scan is recent enough
digest-verificationVerify digests haven’t changed
environment-sequenceEnforce promotion order
custom-opaCustom OPA/Rego policy
webhookExternal 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:

  1. recompute expected evidence_score from reproducibility inputs (canonical_bom_sha256, payload_digest, sorted attestation_refs)
  2. query Evidence Locker by artifact_id
  3. require status=ready
  4. require exact score equality

Violation codes emitted for this flow:


References