A run is one attempt to evaluate an immutable configuration and input snapshot. StackShip executes managed runs with OpenTofu in isolated, StackShip-managed sandboxes; Terraform binaries and customer-operated runners are not part of the first release.
Run kinds
| Kind | Behavior |
|---|---|
plan_and_apply |
Plan, policy, approval, then apply |
plan_only |
Plan and policy, then succeed without apply |
speculative |
Pull-request plan and policy; never state-writing or applyable |
Every run pins its configuration digest, input-snapshot digest, exact OpenTofu version, execution region, and platform before execution.
The first release does not support destroy-mode runs. The OpenTofu
compatibility workspace resource advertises allow-destroy-plan,
can-queue-destroy-plan, and permissions.can-queue-destroy as false, and
run creation rejects is-destroy: true without starting execution. This does
not suppress destroy changes discovered by an ordinary plan: those remain part
of the reviewed plan counts and may be applied through the standard
plan_and_apply lifecycle. Workspace-deletion permissions are separate from
destroy-run capabilities.
Sources
Runs can be created from the authenticated v1 API or from the GitHub App source pipeline. GitHub is the only VCS integration today.
A tracked-branch push may create a plan_and_apply run. A pull request
targeting the tracked branch creates a speculative run. Fork pull requests
receive no cloud credential. A same-repository speculative run may receive only
an explicitly configured, verified speculative_plan federation profile.
Sensitive workspace variables are omitted from speculative input snapshots.
StackShip does not accept auto-apply on either the native /v1 run
operation or the OpenTofu /api/v2 compatibility operation. A
plan_and_apply run with changes reaches awaiting_approval and requires an
explicit review action bound to the exact plan, policy result, predecessor
state, and dependency bundle. A policy-passing no-change plan succeeds without
approval or an apply process. Run tofu apply without -auto-approve for the
interactive approval flow. If -auto-approve is supplied, the compatibility
API returns 422 invalid_document with an explicit instruction to rerun
without that flag.
This is a durable invariant, not only an HTTP validation rule. Managed-run
creation and the run repository reject an automatic-apply request before
creating state, every run record is written with auto_apply=false, and
PostgreSQL enforces that value with a check constraint. A stale or untyped
internal caller therefore cannot bypass review.
Review surface
The run page reads top to bottom as commit identity, then decision, then evidence:
- a header whose title is the run’s own message — server-generated for
automated triggers, for example
GitHub <commit-sha>for a push-triggered run — with the Git commit message rendered as a secondary line when present, alongside the run’s status, kind, linked commit SHA, source ref, working directory, commit author, creation time, live duration, copyable run ID, and workspace link; - an attention strip offering Approve plan or Discard run while an approval decision is outstanding, gone once the run moves past it. When the reviewed plan destroys at least one resource, Approve plan arms an inline confirmation that restates the plan counts — including the destroy count — before anything is sent; a plan with zero destroys keeps one-click approval. Discard run likewise arms an inline confirmation, and the discard-reason input appears only inside that confirmation;
- a recovery panel when the run carries unresolved recovery evidence;
- an ordered stage timeline — plan, policy, approval, and apply, each
reporting
pending,running,succeeded,failed,skipped, orwaitingalongside its duration and, for the plan stage, sanitized create, change, and destroy counts. While the plan has not finished, the plan summary is a quiet “Plan not finished yet” note rather than zeroed counts or an attention badge — the attention badge is reserved for a finished plan whose change result genuinely could not be recorded. Skipped stages render dimmed and neutral, not as a warning. Each non-skipped stage is a toggle: expanding it reveals that phase’s inline console — ordered sanitized plan, policy, or apply logs, loaded on first expansion and appended live while the run is not yet terminal — plus stage-specific evidence:- the plan stage additionally offers a Changes tab with the per-resource redacted plan changes behind the plan counts: address, provider, action, and attribute-level before/after values, with sensitive values redacted and unknown values marked. The per-resource view omits resources whose only actions are no-op or read, and classifies create-and-delete pairs as a replace, so its totals can differ from the aggregate counts;
- the policy stage shows sanitized policy findings and the time-bounded
override control, and expands by default whenever the policy outcome is
not
pass; - the approval stage leads with the human facts — whether the plan is awaiting a decision, was approved, was discarded, or expired, with the relevant time and any approval expiry — and demotes the bound plan digest to a secondary monospace line. When the review carries recorded decision evidence, the leading fact names who decided — “Approved by”, “Discarded by”, or “Canceled by” with the decision time — falling back to the monospace actor id when no display name is recorded;
- the apply stage shows the apply status and, when a successor state was committed, the exact published state version, serial, and publication time (the state digest is part of the API response but is surfaced on the workspace state page, not this stage); when no successor state was committed or its record can’t be resolved, it shows a not-recorded or unavailable notice instead. A terminal, applied run expands its last succeeded stage by default so a published state is visible without clicking anything;
- a downloads section exposing the redacted plan to any principal with artifact read access, plus guarded raw-plan and emergency-state downloads for sensitive-access principals. A missing permission collapses to a single quiet note, and a viewer who could never download anything sees no downloads section at all.
A run that ended for a recorded terminal reason surfaces one human sentence
for that reason in the header, with the raw reason code kept as secondary
monospace detail. When the failure came from the plan or apply phase, the
same panel additionally shows the final lines of that phase’s sanitized log
— at most eight — as a monospace block on an ink surface, captioned with
the log it came from, so the underlying error is readable without expanding
a stage. The tail is fetched server-side only for failed terminal runs;
when no log output is available, the panel keeps its sentence-and-code form
unchanged. Two blocked terminal states additionally get a guidance
strip in the approval bar’s position: a plan_credentials_required run links
to the workspace’s cloud access page, and an expired run notes that a new
run starts from a push to the tracked branch or from the CLI, linking to the
workspace source settings.
The page surfaces the run’s available actions directly: approve and reject
drive the approval bar, cancel drives the header’s action button, and a
soft-mandatory policy override drives the policy stage’s override control —
each gated by available_actions and the caller’s permissions. The
run-review response additionally carries evidence fields the page no longer
renders as a dedicated section: the Cloudflare Workflow ID, definition and
build SHA, the OpenTofu version, logical runner release, standard sandbox
base profile, protocol, OPA and Workflow build identities, and each
StackShip attempt’s ID, phase, attempt number, sandbox ID and name, region,
persisted status, and terminal classification. These remain available to any
authenticated API caller with run:read.
The run-review API derives these fields from PlanetScale records. It does not query a live sandbox to reconstruct history. A field that has not been recorded is shown as unavailable or not recorded; the dashboard never invents a commit, sandbox identity, policy outcome, or published state from the top-level run status.
Phase status is a closed contract. Plans use pending, running, finished,
errored, or canceled; applies use the same set plus interrupted. The
client rejects any other value. The dashboard renders pending and running as
informational, finished as success, canceled as attention, and errored or
interrupted as danger. Each phase badge includes its Plan or Apply text label
and an icon, so neither color nor a raw database value carries state alone.
Policy finding titles, messages, and resource addresses are returned as text.
The review response deliberately omits arbitrary finding details, raw policy
input, raw plans, state bodies, artifact object keys, sandbox labels, runner
capabilities, and secret handles. An active soft-mandatory result can display a
time-limited override control only to a principal with
policy:override_soft. The control submits the exact persisted result digest;
hard-mandatory findings cannot be overridden. The review returns at most 256
override records and carries an explicit truncation flag; the organization
audit history remains the durable source for older override events.
An unavailable run request is shown as unavailable, never as an empty run history.
The organization run inventory uses opaque forward pagination. A response
places the continuation token in meta.page.next_cursor; callers pass a
non-null token back through the cursor query parameter. Creation time and run
id form the stable descending key, so runs created at the same instant are not
skipped or repeated. Bare timestamps and delimiter cursors are invalid and
return 400 RUN_LIST_CURSOR_INVALID.
The dashboard’s runs page consumes that same pagination: a Load more runs
control fetches the next page through the browser API path whenever a
continuation token remains. The list offers a free-text filter over run id,
message, and workspace name, plus status and workspace filters that narrow the
already-loaded rows client-side; the status filter initializes from a
?status= query parameter, so other pages can link directly to, for example,
/runs?status=awaiting_approval. Each row shows the run’s duration — final
for terminal runs, still ticking otherwise — and a relative created time with
the absolute UTC form as its tooltip. While any listed run is not yet
terminal, the list re-reads its first page on a short interval so statuses
converge without a manual reload.
While a run is not yet terminal, the dashboard holds a live WebSocket to
wss://stackship.run/live/runs/{run_id}, authenticated by the dashboard
session cookie and a strict Origin check; plan, policy, and apply log events
append to that stage’s inline console in sequence order as they arrive, and
stage or apply state changes refresh the review without a manual reload. Each
console loads its log window lazily, the first time its stage is expanded. A
run that is already terminal when the page loads never opens a socket. On any
sequence gap or disconnect the dashboard falls back to the HTTP log route,
which remains authoritative regardless of what the live channel delivered.
The dashboard keeps at most the newest 512 KiB of decoded log text per
console either way. While the live feed is attached, each console appends on
its own and shows no manual control; once the feed is not live — the run is
terminal or the socket is down — a Refresh log control re-reads the HTTP
route on demand. If the socket fails repeatedly, the page reports a quiet
“Reconnecting — data may be stale” note beside the status badge and falls
back to polling the review endpoint on an interval until the socket
recovers, without changing any log-delivery semantics.
Ordinary artifact access exposes the redacted plan. Raw plan and
emergency-state controls require sensitive permission, a typed operator
reason, and step-up reauthentication no older than 15 minutes. The dashboard
presents guarded POST forms only to authorized operators; emergency state
additionally requires state:recover, and run:recover alone does not
authorize the download. Restricted download routes reject every non-empty
query string. The reason is audited before any bytes are released.
StackShip now publishes a distinct, renderable plan-diff artifact —
stackship.plan-diff/v1 — built by the runner during the plan phase.
Sensitive values are redacted through the plan’s own sensitivity masks before
the artifact ever leaves the sandbox, and values OpenTofu marked unknown are
marked as such rather than guessed. The artifact is bounded to 5,000
resources and 4 MiB, and the two bounds enforce differently. A plan with more
than 5,000 changed resources has the excess cut from the input list outright,
before redaction ever runs. A plan that still exceeds 4 MiB after that cutoff
degrades resource by resource instead — withholding the largest remaining
resource’s before/after detail while keeping its address, provider, and
actions, repeating until the artifact fits, and only dropping trailing
resources entirely if withholding every detail still is not enough. Either
path sets an explicit truncated flag. Construction also fails closed on a
per-resource basis: a resource whose sensitivity masks cannot be resolved is
emitted with its detail withheld rather than with unredacted values.
GET /v1/runs/{runId}/plan-changes serves this artifact to a principal with
run:read, and the dashboard’s inline plan-changes view and its
“Download redacted plan” control both read from it now.
StackShip still advertises structured run output as disabled to OpenTofu. The
CLI therefore renders the standard human-readable plan logs streamed from the
run instead of requesting a redacted structured-output artifact through the
TFE-compatible protocol. Publishing plan_diff closes this gap for the
dashboard; StackShip will not claim TFE structured-output compatibility until
the compatibility API exposes an equivalent redacted artifact to the OpenTofu
CLI.
The OpenTofu Cloud compatibility contract separately exposes
GET /api/v2/plans/:plan_id/json-output for authenticated automation that
needs the machine-readable plan export. This endpoint is not the CLI’s
redacted structured-output endpoint. It requires a cloud principal with
run:read inside the exact organization, project, and workspace boundary. The
Worker streams the encrypted plan_json artifact with backpressure and a
100 MB limit; it does not accumulate the plan in Worker memory. Artifact
metadata and the object descriptor must agree on workspace, kind, plaintext
size, and SHA-256 before the stream is released. Each encrypted frame is
authenticated, the stream enforces the exact declared plaintext length without
releasing a trailing byte, and the object store verifies the complete artifact
digests at EOF. Missing, unauthorized, oversized, or inconsistent descriptors
return the same unavailable response, while a descriptor mismatch or invalid
stream is canceled. Successful JSON-plan responses use application/json with
Cache-Control: private, no-store; compatibility errors remain JSON:API
documents.
State and apply
Only a successful apply may commit its Cloud-published successor state version. OpenTofu streams that version through the TFE-compatible API, and the runner confirms only its exact publication metadata instead of copying state through the sandbox callback. Apply authorization binds the reviewed evidence to the current fenced workspace lock. An interrupted or uncertain apply does not guess whether the provider changed infrastructure; it ends in an explicit recovery state for operator handling.
What is not included
StackShip does not include:
- scheduled runs or drift scans;
- cost analysis;
- customer-operated execution agents;
- a StackShip-specific CLI;
- general Slack, PagerDuty, email, or customer webhook routing.