Promotion & Approval APIs
API endpoints for managing promotions, approvals, and gate evaluations.
Status: Planned (not yet implemented) Source: Architecture Advisory Section 6.3.5 (archived) Related Modules: Promotion Manager Module, Workflow Promotion
Runtime note: promotion libraries and tests exist, while the API host controllers for these endpoints are tracked in
../promotion-runtime-gap-closure-plan.md. Compatibility note (2026-04-27):/api/v1/release-orchestrator/releases/{id}/promoteand/deployare implemented as transitional release-control endpoints. Release and approval compatibility records are stored inrelease_orchestratorrelease-truth tables. Promote/deploy fail closed when approval, gate-decision, or verified evidence truth is missing.Promotion target references accept the legacy
env-*aliases, environment names, or UUIDs. A resolved target persists its canonical name and configured approval quorum (minimum one); an explicit positive gate/policyrequiredApprovalsvalue overrides it. Unresolved legacy references retain the compatibility quorum of two.
Queue hygiene (as built — Sprint 20260703_002 / DTC-13)
Approval rows are reconciled lazily on read (no hosted sweep job; offline-friendly). Every queue read surface (GET /api/v1/release-orchestrator/approvals, GET /api/v1/approvals, single-approval reads, and the promote path before its dedupe check) funnels rows through ApprovalQueueHygiene (src/ReleaseOrchestrator/__Apps/StellaOps.ReleaseOrchestrator.WebApi/Services/ApprovalQueueHygiene.cs), which transitions and persists:
- Expiry —
pending+ pastexpiresAt⇒expired. Expired rows leave pending counts/badges;POST …/approvals/{id}/approve|rejecton an expired row returns409 { "error": "approval_expired" }. - Supersede — multiple
pendingrows for the same (release, target environment): the newest request survives, older duplicates ⇒superseded(action record names the surviving approval id). A new promote request therefore supersedes older pendings. - Deploying reconcile — an approval stranded in
deploying(the deploying→deployed transition runs only inside the in-process deploy scope, so a service restart mid-deploy strands it) is reconciled against the latest matchingrelease_orchestrator.deploymentsrow:succeeded⇒deployed,failed⇒failed,cancelled⇒cancelled,rolled_back⇒rolled_back; in-flight deployments are left untouched.
Every hygiene transition appends an action record (actor release-orchestrator) so the history stays auditable.
Effective state and the expiry boundary (SPRINT_20261007_004 / CCP-008). The status returned by every read above is the effective state; no additional field carries it. The boundary is the service clock (TimeProvider): a pending row whose expiresAt is at or before the current time is expired, and one second earlier it is still pending. Only pending rows expire. A row without expiresAt never expires and is returned with "expiresAt": null; the service does not derive an expiry from another timestamp. Approved, rejected, superseded, cancelled and deployment-state rows keep their status whatever their recorded expiresAt. When the transition cannot be persisted, the read returns the stored row unchanged (a read never presents an unpersisted state).
Queue filters. GET /api/v1/release-orchestrator/approvals filters on statuses, urgencies and environment (comma-separated, case-insensitive). GET /api/v1/approvals filters on status and targetEnvironment. Both filter after hygiene, so a status filter sees the effective state.
Console consumption. The Approvals queue classifies, counts and filters rows by the returned status only: the Pending, Approved, Rejected and Expired tabs list rows of that status, and Expiring lists pending rows whose readable expiresAt is at most 24 hours ahead, including a deadline that has already passed while the service still returns pending. Each tab badge counts exactly the rows its tab lists. The Expires column and the decision cockpit show the deadline of a pending request (No expiry when none was returned); a decided request is never labelled expired. The cockpit names a decider only for a decided status: an approved action below the required approvals leaves the request pending, and the cockpit names no decider for it.
Decision route used by the Console (SPRINT_20261007_004 / CCP-009). POST /api/v1/approvals/{id}/decision accepts { "action": "approve" | "reject", "comment", "operatorDecision" } and requires release:publish (policy ReleaseApprove). It runs the same single-row hygiene first, then accepts a decision only on a pending request: an expired request (including a pending row whose expiry has just passed on the service clock) returns 409 { "error": "approval_expired" }; any other status (superseded, cancelled, blocked, approved, rejected, deploying, deployed, failed, rolled_back) returns 409 { "error": "approval_not_pending" }. A rejected request therefore cannot be approved into a deployment dispatch on this route. The decision is attributed to the authenticated principal (ActorResolution.ResolveDisplayName, as on the v1 routes); the body carries no actor, so per-actor quorum counting cannot be satisfied by one operator posting under two names. An approve additionally passes the approval safety guard (gate truth, evidence, signing); a reject does not, so a request with a waiting or blocked gate can be rejected.
Known gaps (recorded in SPRINT_20261007_004). The v1 …/approvals/{id}/approve|reject routes refuse only expired requests and the batch routes skip them; these routes still accept a decision on a superseded, cancelled or decided request. GET /api/v1/release-orchestrator/dashboard counts pendingApprovals from stored rows without hygiene.
Bulk cancel: POST /api/{v1/}release-orchestrator/approvals/batch-cancel { "ids": [...], "comment": "..." } cancels OPEN rows (pending/expired) and returns { "cancelled": [summaries], "skippedIds": [...] }. Authorization: policy release-orchestrator.approval.cancel — satisfied by release:write or release:publish (any-of).
Requester attribution: requestedBy and decision action records now carry the actor’s display name. The gateway forwards the token’s name/preferred_username/email claims through the identity envelope (they were previously dropped, so services stored the raw sub UUID); ActorResolution.ResolveDisplayName prefers those claims over the opaque subject, which is still what audit rows record as actorId. Both values come from the authenticated principal; the X-StellaOps-Actor request header is not read (actor attribution). Historical UUID rows are labelled honestly in the console (“Unresolved identity (…)” / “System (…)”).
Overview
The Promotion API provides endpoints for requesting release promotions between environments, managing approvals, and evaluating promotion gates. Promotions require configured approvals before deployment proceeds; where an environment requires separation of duties, the requester’s own approval is refused (see the approve notes).
Gate contract references:
../../policy/promotion-gate-ownership-contract.md../../evidence-locker/promotion-evidence-contract.md
Promotion Endpoints
Create Promotion Request
Endpoint: POST /api/v1/promotions
Initiates a promotion request for a release to a target environment.
Request:
{
"releaseId": "uuid",
"targetEnvironmentId": "uuid",
"reason": "Deploying v2.3.1 with critical bug fix"
}
Response: 201 Created
{
"id": "uuid",
"releaseId": "uuid",
"releaseName": "myapp-v2.3.1",
"sourceEnvironmentId": "uuid",
"sourceEnvironmentName": "Staging",
"targetEnvironmentId": "uuid",
"targetEnvironmentName": "Production",
"status": "pending",
"requestedBy": "user-uuid",
"requestedAt": "2026-01-10T14:23:45Z",
"reason": "Deploying v2.3.1 with critical bug fix"
}
Status Flow:
pending -> awaiting_approval -> approved -> deploying -> deployed
-> rejected
-> cancelled
-> failed
-> rolled_back
# DTC-13 queue hygiene (lazy-on-read):
pending -> expired (past expiresAt without a decision)
pending -> superseded (newer pending request for the same release + target env)
pending -> cancelled (bulk-cancel from the approvals queue)
List Promotions
Endpoint: GET /api/v1/promotions
Query Parameters:
status(string): Filter by statusreleaseId(UUID): Filter by releaseenvironmentId(UUID): Filter by target environmentpage(number): Page number
Response: 200 OK
{
"data": [
{
"id": "uuid",
"releaseName": "myapp-v2.3.1",
"targetEnvironmentName": "Production",
"status": "awaiting_approval",
"requestedAt": "2026-01-10T14:23:45Z"
}
],
"meta": { "page": 1, "totalCount": 25 }
}
Get Promotion
Endpoint: GET /api/v1/promotions/{id}
Response: 200 OK - Full promotion with decision record and approvals
Approve Promotion
Endpoint: POST /api/v1/promotions/{id}/approve
Request:
{
"comment": "Approved after reviewing security scan results"
}
Response: 200 OK
{
"id": "uuid",
"status": "approved",
"approvalCount": 2,
"requiredApprovals": 2,
"decidedAt": "2026-01-10T14:30:00Z"
}
Notes:
- Separation of Duties (SoD): where the target environment sets
requireSeparationOfDuties(environment create, update and settings routes), every Release Orchestrator approve route (v1 single and batch, v2 decision, dashboard) refuses an approval by the promotion’s requester with403 separation_of_duties_self_approvaland records no decision (the route’s HTTP audit event still records the refused attempt); another approver must decide, and the requester can still reject. A request that records no requester is refused with409 separation_of_duties_requester_unrecorded. WhererequireSeparationOfDutiesis off, the requester’s own approval is accepted. See “Separation of duties on approve” in the architecture dossier. - Multi-party approval: Promotion proceeds when
approvalCount >= requiredApprovals - Approval decisions are idempotent per actor while the request is pending: repeated approvals by the same authenticated actor do not increase
approvalCountor create duplicate durable action records.
Reject Promotion
Endpoint: POST /api/v1/promotions/{id}/reject
Request:
{
"reason": "Security vulnerabilities not addressed"
}
Response: 200 OK - Updated promotion with status: rejected
Cancel Promotion
Endpoint: POST /api/v1/promotions/{id}/cancel
Cancels a pending or awaiting_approval promotion.
Response: 200 OK - Updated promotion with status: cancelled
Decision & Evidence Endpoints
Compatibility evidence endpoints are backed by durable release truth. Evidence list/detail/verify/export/raw/timeline routes read release_orchestrator.release_evidence for the calling tenant and return 404 when the packet is not stored there. Promotion and deployment checks use those durable packets plus release_orchestrator.gate_decisions; they fail closed instead of projecting passing decisions from demo rows.
Get Decision Record
Endpoint: GET /api/v1/promotions/{id}/decision
Returns the full decision record including gate evaluations.
Response: 200 OK
{
"promotionId": "uuid",
"decision": "allow",
"decidedAt": "2026-01-10T14:30:00Z",
"gates": [
{
"gateName": "security-gate",
"passed": true,
"details": {
"criticalCount": 0,
"highCount": 3,
"maxCritical": 0,
"maxHigh": 5
}
},
{
"gateName": "freeze-window-gate",
"passed": true,
"details": {
"activeFreezeWindow": null
}
}
],
"approvals": [
{
"approverId": "uuid",
"approverName": "John Doe",
"decision": "approved",
"comment": "LGTM",
"approvedAt": "2026-01-10T14:28:00Z"
}
]
}
Get Approvals
Endpoint: GET /api/v1/promotions/{id}/approvals
Response: 200 OK - Array of approval records
Get Evidence Packet
Endpoint: GET /api/v1/promotions/{id}/evidence
Returns the signed evidence packet for the promotion decision.
Response: 200 OK
{
"id": "uuid",
"type": "release_decision",
"version": "1.0",
"content": { ... },
"contentHash": "sha256:abc...",
"signature": "base64-signature",
"signatureAlgorithm": "ECDSA-P256-SHA256",
"signerKeyRef": "key-id",
"generatedAt": "2026-01-10T14:30:00Z"
}
Gate Preview Endpoints
Preview Gate Evaluation
Endpoint: POST /api/v1/promotions/preview-gates
Evaluates gates without creating a promotion (dry run).
Request:
{
"releaseId": "uuid",
"targetEnvironmentId": "uuid"
}
Response: 200 OK
{
"wouldPass": false,
"gates": [
{
"gateName": "security-gate",
"passed": false,
"blocking": true,
"message": "3 critical vulnerabilities exceed threshold (max: 0)"
},
{
"gateName": "freeze-window-gate",
"passed": true,
"blocking": false,
"message": "No active freeze window"
}
]
}
Approval Policy Endpoints
Approval policies are currently implemented by the Release Orchestrator WebApi under /api/v1/release-orchestrator/approval-policies. They are tenant-scoped, stored in release_orchestrator.approval_policies, and drive promotion gate decisions before deployment. GET requires release.policy.read; create, update, and delete require release.policy.manage. Delete soft-disables the policy (enabled=false) so historical decisions remain replayable.
Create Approval Policy
Endpoint: POST /api/v1/release-orchestrator/approval-policies
Request:
{
"policyId": "SP-001",
"name": "production-policy",
"description": "Production changes require two approvers.",
"sourceEnvironment": "staging",
"targetEnvironment": "production",
"releaseNameGlob": "payments-*",
"mode": "require-manual",
"requiredApprovals": 2,
"reason": "Production promotion requires two human approvals.",
"enabled": true,
"priority": 100
}
Modes: auto-approve, require-manual, deny.
List Approval Policies
Endpoint: GET /api/v1/release-orchestrator/approval-policies
Query Parameters:
sourceEnvironment(string): include policies matching the source or with no source predicatetargetEnvironment(string): include policies matching the target or with no target predicatereleaseName(string): client-side glob match againstreleaseNameGlobname(string): substring match against name or policy idincludeDisabled(boolean): include soft-disabled policies
Get Approval Policy
Endpoint: GET /api/v1/release-orchestrator/approval-policies/{id}
Update Approval Policy
Endpoint: PUT /api/v1/release-orchestrator/approval-policies/{id}
Delete Approval Policy
Endpoint: DELETE /api/v1/release-orchestrator/approval-policies/{id}
Current User Endpoints
Get My Pending Approvals
Endpoint: GET /api/v1/my/pending-approvals
Returns promotions awaiting approval from the current user.
Response: 200 OK - Array of promotions
Error Responses
| Status Code | Description |
|---|---|
400 | Invalid promotion request |
403 | User cannot approve (SoD violation or not in approver list) |
404 | Promotion not found |
409 | Promotion already decided |
422 | Gate evaluation failed |
For transitional /api/v1/release-orchestrator/releases/{id}/promote and /deploy, 409 Conflict is also returned with { blocked: true, reason, message } when the action cannot be proven safe from durable truth: missing approval quorum, missing gate-decision details, non-passing gates, missing verified evidence, missing verified policy-decision evidence for promotion, or evidence hash mismatch.
See Also
- Workflows API (archived)
- Releases API
- Promotion Manager Module
- Security Gates
