Skip to content

Run approvals

Exact-evidence approval and rejection for managed OpenTofu runs.

Updated View as Markdown

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:

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.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close