# 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**: ```typescript 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**: ```typescript interface ApprovalPolicy { id: UUID; tenantId: UUID; environmentId: UUID; requiredCount: number; // Minimum approvals required requiredRoles: string[]; // At least one approver must have role requiredGroups: string[]; // At least one approver must be in group requireSeparationOfDuties: boolean; // Requester cannot approve allowSelfApproval: boolean; // Override SoD for specific users expirationMinutes: number; // Approval expires after N minutes } 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` | 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**: ```typescript 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; 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` | 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**: ```typescript 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 ```sql -- 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.approval_policies ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, environment_id UUID NOT NULL REFERENCES release.environments(id) ON DELETE CASCADE, required_count INTEGER NOT NULL DEFAULT 1, required_roles JSONB NOT NULL DEFAULT '[]', required_groups JSONB NOT NULL DEFAULT '[]', require_sod BOOLEAN NOT NULL DEFAULT FALSE, allow_self_approval BOOLEAN NOT NULL DEFAULT FALSE, expiration_minutes INTEGER NOT NULL DEFAULT 1440, UNIQUE (tenant_id, environment_id) ); ``` --- ## API Endpoints ```yaml # 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[] } # Approval Policies POST /api/v1/approval-policies GET /api/v1/approval-policies GET /api/v1/approval-policies/{id} PUT /api/v1/approval-policies/{id} DELETE /api/v1/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: ```typescript 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: - `SEC_REPRO_EVIDENCE_ARTIFACT_MISSING` - `SEC_REPRO_EVIDENCE_SCORE_INPUT_INVALID` - `SEC_REPRO_EVIDENCE_SCORE_REFS_INVALID` - `SEC_REPRO_EVIDENCE_SCORE_MISSING` - `SEC_REPRO_EVIDENCE_SCORE_NOT_READY` - `SEC_REPRO_EVIDENCE_SCORE_MISMATCH` --- ## References - [Module Overview](overview.md) - [Workflow Engine](workflow-engine.md) - [Security Architecture](../security/overview.md) - [API Documentation](../api/promotions.md)