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 → expiredExpiration 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.