---
title: "Run approvals"
description: "Exact-evidence approval and rejection for managed OpenTofu runs."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.stackship.run/llms.txt
> Use this file to discover all available pages before exploring further.

# Run approvals

A `plan_and_apply` run reaches `awaiting_approval` only after a plan with
changes and its policy evaluation succeed. A successful no-change plan
terminates with approval and apply marked skipped; OpenTofu is not invoked for
a no-op apply. The dashboard offers approve or reject when the authenticated
principal has `run:approve`, the exact approval binding exists, and the action
is still valid. A closed policy gate still permits rejection, but approval is
not advertised. Missing binding evidence advertises neither action, and an
infrastructure failure while checking the gate fails the review request instead
of being mistaken for an ordinary policy block.

Plan-only and speculative runs never enter approval or apply.

## Approval binding

The review response carries the exact evidence a decision will authorize:

- run id;
- plan digest;
- dependency-bundle digest;
- predecessor state-version id and digest, when state exists;
- policy-evaluation id; and
- policy-result digest.

Approve and reject submit that complete binding with an idempotency key. The
database compares it to the current evidence in the same transaction that
records the decision. A mismatch returns a stale-evidence conflict; the
reviewer must reload and make a new decision.

This prevents a decision made for one plan, policy result, or predecessor state
from authorizing another.

## Decision evidence

Once a decision is recorded, the run review returns it as display-safe
evidence: the action (`approved`, `rejected`, or `canceled`), the deciding
actor's id and display name, and the exact decision instant. The dashboard's
approval stage leads with that fact — "Approved by …", "Discarded by …", or
"Canceled by …" with the decision time — and falls back to the actor id in
monospace when no display name is recorded. An undecided or expired review
carries no decision evidence, so the stage keeps its plain what-and-when
sentence for those runs.

## Decision deadline

The approval window is seven days. PlanetScale stores the authoritative
`approval_expires_at` instant when the policy result commits
`awaiting_approval`; the run review returns that exact UTC value and the
dashboard displays it without deriving a deadline in the browser.

Cloudflare Workflows waits seven days for a user action. Cloudflare reports a
wait timeout as an untyped thrown error, so StackShip does not classify the
exception by its message. Instead, any failed user-action wait invokes one
durable, authenticated, bodyless arbitration step. The database locks the run
and returns one bounded authority: expired, a persisted pre-deadline approval
or rejection, a persisted cancellation, or still pending. A pending result
rethrows the original wait failure. A persisted action resumes the same path
as its Workflow event. The database expires the run only when its persisted
deadline is due. The transition is idempotent:

```text
awaiting_approval → expired
```

Expiration records terminal reason `APPROVAL_EXPIRED`, closes the approval
stage, releases the workspace writer reservation, synchronizes the terminal
GitHub Check, and lets the Workflow perform its normal sandbox and run cleanup.
If Check synchronization fails, the internal service request fails so the
durable Workflow step retries it. Replaying expiration cannot duplicate the
terminal transition or audit event. Expiration never prepares apply.

An approve or reject request that reaches the database at or after the deadline,
or a legacy waiting run that has no authoritative deadline, returns
`409 APPROVAL_EXPIRED`. It records no approval or Workflow signal and does not
perform an apply revalidation or nonterminal GitHub synchronization. The
deadline judges when the decision commits: expiration cannot overwrite a
decision that won the run lock before the deadline, and delayed Workflow
delivery does not invalidate that decision. An exact retry receives its durable
idempotent response. An expired run cannot be reopened; create a new run to
produce and review new evidence.

The same review includes the sanitized policy findings and the newest 256
persisted overrides for that evaluation. An explicit truncation flag directs
reviewers to the organization audit history when older override evidence
exists. A current soft-mandatory result remains blocked until each
finding-bearing result has an unexpired override bound to its exact policy
version and result digest. The dashboard shows the override control only when
the principal has `policy:override_soft`; the API rechecks permission and
eligibility and remains authoritative. Hard-mandatory results never expose an
override control.

## Approve

Approval writes immutable decision evidence and signals the run Workflow.
Immediately before starting apply, StackShip also acquires the workspace state
lock, binds its fence to the apply authorization, and rechecks the production
launch controls, suspension state, logical runner release, and execution
region.

In the dashboard, a plan whose destroy count is zero approves in one click.
A plan that destroys at least one resource arms an inline confirmation
instead: the confirmation restates the plan's create, change, and destroy
counts, and only its explicit confirm control submits the approval. Arming
moves keyboard focus to the confirmation; declining returns focus to the
approve trigger and sends nothing.

A response-loss retry with the same key and request fingerprint receives the
same durable status and response data. The outer `meta.request_id` is fresh for
each HTTP request so the original attempt and its replay remain independently
traceable. Reusing the key with changed evidence is rejected. A gate rejection
rolls back its idempotency reservation, so the same exact request can be
retried after the gate becomes valid and then replayed normally.

## Reject

Rejection requires a non-empty human reason. It writes the same digest-bound
evidence, moves the run to `rejected`, and records an audit event before GitHub
Check synchronization is attempted.

The dashboard's vocabulary for this action is discard: the control is
**Discard run**, and a `rejected` run's status badge reads **Discarded**.
The control arms an inline confirmation that collects the discard reason —
the reason input exists only inside that confirmation — and only its
**Confirm discard** control submits the decision.

A temporary GitHub or Workflow delivery error therefore does not roll back the
human decision. Reconciliation retries only the pending side effect.

## Cancel

Cancellation is a separate action guarded by `run:cancel`. The dashboard shows
it only when `available_actions` includes `cancel` and requires a second browser
confirmation. Arming that guard moves keyboard focus to the explicit
confirmation control; keeping the run returns focus to the cancellation
trigger. Cancellation does not stand in for rejection and does not create
approval evidence.

The native cloud contract also advertises an `applying` run as cancelable so
OpenTofu can submit its standard cancel action. If that durable cancellation
wins the apply-result event race, StackShip stops the exact recorded
sandbox before recording `apply_interrupted`. It retains the workspace writer
fence for explicit recovery and deletes the stopped sandbox through idempotent
run cleanup. An exact cancellation retry reuses the original signal.

## Failure behavior

- A missing permission returns `403`.
- A late approve or reject returns `409 APPROVAL_EXPIRED`.
- A run that is no longer actionable returns a conflict.
- Changed bound evidence returns a stale-evidence conflict.
- A changed payload under the same idempotency key is rejected.
- Database failure records no partial decision.
- Downstream GitHub or Workflow failure leaves the durable decision intact for
  reconciliation.

## Related pages

- [Run lifecycle](/runs/lifecycle)
- [Policy sets](/governance/policies)
- [Audit events](/governance/audit)

Source: https://docs.stackship.run/runs/approvals/index.mdx
