StackShip evaluates Open Policy Agent Rego after every managed OpenTofu plan. Policy is a required run stage: speculative runs stop after plan and policy, while applyable runs cannot reach approval or apply until the persisted policy gate allows it. This is StackShip’s native Rego contract, not Sentinel compatibility; StackShip does not evaluate Sentinel.
Security and determinism
Policy evaluation receives a sanitized derivative of opentofu show -json,
never the raw plan, state, configuration archive, variables, credentials, or
repository contents. Sensitive before and after subtrees are replaced with
null before OPA starts; the corresponding boolean sensitivity maps remain so
a policy can distinguish redaction from an ordinary null value. A secret-canary
match fails the stage closed.
Every evaluation is bound to:
- the immutable plan digest and policy-input digest;
- the snapshotted attachment, policy-set, and policy-version identities;
- the immutable policy-bundle digest;
- OPA
1.18.2; stackship-rego-capabilities-v1and its committed SHA-256 digest; and- the fixed entrypoint
data.stackship.v1.decision.
The capabilities manifest permits only deterministic, side-effect-free
built-ins. Network, DNS, host runtime, tracing, printing, clocks, randomness,
UUIDs, cryptography, JWT operations, and dynamic Rego parsing are unavailable.
An undefined decision, schema error, forbidden capability, timeout, resource
limit, or evaluator failure produces the error outcome and blocks apply.
Immutable policy versions
A policy set is a stable named identity. Publication creates a monotonically numbered version; a published version is never edited in place.
Each policy version also pins the workspace settings version that was current when its source artifact became the publication request. That immutable settings identity determines the policy builder’s OpenTofu version and execution region. Changing the workspace’s current OpenTofu version or region affects only policy versions created afterward; retries of an existing pending version cannot be retargeted to a different logical runner release or region.
POST /v1/policy-setscreates the set.POST /v1/workspaces/{workspace_id}/policy-sets/{policy_set_id}/versionsstreams a gzip-compressed tar archive into encrypted restricted storage and records a pending publication request for its committed digest.- A dedicated sandboxed policy-build attempt runs strict Rego validation, tests, and bundle creation with pinned OPA and capabilities.
- The trusted completion service verifies source, bundle, and test-result
artifact digests before atomically marking the version
published.
Public clients cannot supply status, bundle, test-result, OPA, capabilities,
or entrypoint claims. Failed compilation or tests never create an attachable
version.
The version request returns 202 after the API has atomically created the
pending row, audit evidence, and durable dispatch record. It then attempts the
deterministic policy-publication-{policy_version_id} Cloudflare Workflow.
If immediate delivery is unavailable, the pending result remains committed
and the orchestrator’s authenticated scheduled outbox reconciler retries it.
Workflow retries reuse the same attempt, sandbox labels, execution
nonce, R2 logical object keys, and artifact digests; they never create a
replacement build with different authority.
Send Idempotency-Key on every policy mutation. The API stores the exact
successful response with the mutation. An exact replay returns that response
without directly redispatching the Workflow; reusing the key with changed
source identity conflicts instead of creating another version.
The source-upload request uses Content-Type: application/gzip. Its body is a
.tar.gz archive of Rego source and tests, limited to 100 MB. The API derives a
tenant-, workspace-, set-, and idempotency-bound object identity, streams the
request directly into encrypted R2, and creates the immutable version from the
committed artifact metadata. It rejects an oversized declared length before
reading the body and cancels an unknown-length stream as soon as it exceeds the
limit. The legacy set-scoped JSON version endpoint remains available to trusted
clients that already have a restricted source-artifact ID and digest.
The encrypted source object’s artifact identity is persisted with that upload and is the exact authenticated-encryption context used when the policy builder later reads the archive. The public artifact metadata ID is only an API reference and is never substituted as encryption context. This keeps publication bound to the immutable uploaded object even when source bytes are streamed through separate Worker requests.
The policy builder is a dedicated mode in the generated StackShip runner bundle. Its disposable sandbox starts from the standard managed base, receives the bundle, and installs checksum-verified OPA on demand. The sandbox has unrestricted public egress because bootstrap downloads the pinned toolchain; network isolation is not the policy security boundary. Fixed OPA capabilities, sanitized input, and the absence of customer cloud credentials constrain policy execution. A short-lived bootstrap binds one organization, policy version, source digest, attempt, execution nonce, and sandbox. Source bytes stream from encrypted R2 through the API into the container. Bundle and test-result bytes stream back through the API into immutable encrypted R2 objects; neither direction is aggregated in Worker memory.
The completion callback does not choose artifact identity. The API re-reads the
tenant-scoped source, bundle, and test-result rows from PlanetScale, compares
their committed plaintext SHA-256 digests, and calls the atomic publication
transition. An identical callback is an exact replay. A changed digest,
artifact, tenant, version, or terminal state is a conflict. Compilation,
tests, bundle creation, output bounds, toolchain verification, or terminal
orchestration failures transition the pending version to failed with a safe
failure_code; failed versions remain non-attachable.
Attachments and run snapshots
Each attachment pins one published version, one scope, and one enforcement mode:
| Scope | Target |
|---|---|
organization |
Every workspace in the organization |
project |
Every workspace in one StackShip project |
workspace |
One workspace |
Attachments are additive and deterministically ordered by organization, project, then workspace scope, followed by attachment ID. One policy set may appear only once in a workspace’s effective ancestry. Attaching the same set at two applicable scopes is a configuration error; StackShip does not silently pick one.
The effective attachment list is snapshotted when the run is created. Later attachment or publication changes affect new runs only. The snapshot copies the exact bundle and evaluator contract, so cleanup or metadata changes cannot reinterpret an existing result.
List the active attachments for one workspace:
GET /v1/workspaces/{workspace_id}/policy-attachmentsCreate a workspace attachment with:
POST /v1/workspaces/{workspace_id}/policy-attachments
Content-Type: application/json
{
"policy_set_id": "pol_22222222222222222222222222222222",
"policy_version_id": "pol_33333333333333333333333333333333",
"enforcement_mode": "soft_mandatory"
}Detach an active workspace attachment with:
DELETE /v1/workspaces/{workspace_id}/policy-attachments/{attachment_id}Detach is compare-and-set: an already detached or cross-tenant attachment is not treated as active. Existing run snapshots retain the immutable attachment facts they captured before detach.
Dashboard policy workflow
The dashboard lists policy sets in a single filterable table showing each
set’s latest immutable publication state; the row links to the set’s detail
page. Creating a policy set creates only its stable identity, and the
dashboard opens the new set’s page as soon as creation succeeds. StackShip is
not a Rego editor: under Upload Rego source, a policy writer selects the
build workspace and a local .tar.gz source archive. The browser sends that File
as the request body rather than serializing or reading it into JavaScript
memory, and the dashboard proxy and API preserve streaming backpressure.
Submission records a pending immutable version. Each version card on the
detail page leads with the version number, publication status, and request
time; the pinned OPA, capabilities, entrypoint, source digest, and bundle
digest sit behind that card’s Build details disclosure. Retrying the same selected archive and build workspace reuses its
idempotency key so a lost response can be recovered. Selecting a different
archive or workspace rotates the key before publishing; the browser still does
not read or buffer the archive.
After the API accepts a publication, the dashboard keeps a policy-set-scoped
confirmation for the browser session so a server-tree refresh does not discard
the result. It refreshes server data every 2.5 seconds for at most 12 checks and
stops immediately when the version becomes published or failed. A terminal
message explains whether the version is ready to attach or needs corrected
source. If the build remains pending after 30 seconds, automatic checks pause
and the dashboard offers an explicit Refresh status action that begins
another bounded check window. Leaving the page tears down the active timer.
Feedback never includes the local source filename or arbitrary server error
text.
The policy set detail page ends with an Attach to a workspace section
that links each workspace’s policy settings page, so a freshly published
version can be attached without hunting for the right settings screen. Under
Workspace → Settings → Policies, a policy writer can attach only a
version whose API status is published. Workspace scope and target are fixed by
the page; the writer chooses advisory, soft_mandatory, or
hard_mandatory. Every workspace reader sees the active attachment inventory,
including the exact set, version, enforcement label, attachment ID, and
creation time. A writer can detach an active attachment from that inventory
only after a second explicit confirmation. The warning explains that detach
affects future run snapshots while existing run evidence remains immutable,
and keyboard focus stays on the confirmation control.
Policy input v1
The complete input is strict stackship.policy.input/v1:
{
"version": "stackship.policy.input/v1",
"run": {
"kind": "tracked"
},
"source": {
"commit_sha": "0123456789abcdef0123456789abcdef01234567",
"repository_id": "123456789",
"working_directory": "."
},
"workspace": {
"id": "ws_22222222222222222222222222222222",
"metadata": {
"environment": "production"
}
},
"resource_changes": [
{
"address": "aws_s3_bucket.assets",
"provider_name": "registry.opentofu.org/hashicorp/aws",
"change": {
"actions": ["create"],
"before": null,
"after": {
"bucket": "northwind-assets",
"secret": null
},
"before_sensitive": false,
"after_sensitive": {
"secret": true
}
}
}
]
}The allowed run kinds are manual, tracked, and speculative. Resource
addresses must be unique. OpenTofu action tuples are validated rather than
accepted as arbitrary strings. The complete document is limited to 2 MiB,
50,000 nodes, 5,000 resource changes, and 20,000 sensitivity-map nodes.
Objects, arrays, strings, and depth are bounded independently as well.
Source provenance is cross-validated with the run kind. Tracked and
speculative runs carry the exact 40-character GitHub commit and provider
repository ID from the immutable run-input snapshot. A manual run has no Git
commit: its commit_sha is null, and repository_id is the immutable
cfg_... configuration-version ID. StackShip rejects a manual input with a
fabricated commit or a tracked input without one.
Unknown top-level fields are rejected. In particular, raw_plan, state,
variables, and credentials are not part of this schema.
The policy execution manifest reads only the attachment snapshot recorded
before workflow dispatch. Its common artifact grants are exactly the immutable
run-input snapshot and successful JSON plan. Each of at most 256 ordered
evaluations receives a different, attempt-bound bundle capability whose URL is
discriminated by ?evaluation={sequence}. Bundle capabilities are stateless
HMAC grants and may be retried with the same bound session and request until
their deadline; they are not one-time redemption handles. After verifying the
grant and bundle digest, the runner passes the standard OPA bundle directly to
opa eval --bundle; it never extracts customer-controlled bundle paths into
the sandbox filesystem. The sandbox receives no source archive, saved plan,
state, run token, secret handle, or workload credential. Both the sanitized
input and complete composite result are capped at 2 MiB.
Result contract
Every bundle defines exactly this entrypoint:
data.stackship.v1.decisionIt returns one strict object:
{
"findings": [
{
"id": "encryption-required",
"title": "Encryption required",
"message": "Enable encryption before apply.",
"resource_address": "aws_s3_bucket.assets",
"details": {
"control": "storage-encryption"
}
}
]
}findings is required; an empty array passes. Finding IDs must be unique in the
result. A finding ID is limited to 128 characters, title to 160, message to
4,000, resource address to 512, and the optional details object is
depth-, key-, item-, and byte-bounded. Unknown fields and more than 500
findings fail closed.
Each persisted result records its decision or safe error code together with the attachment, set, version, scope, enforcement mode, input digest, and result digest. The composition digest covers the ordered attachment/result digest pairs and overall outcome. Dashboard, API, approval evidence, and OpenTofu cloud compatibility all read this same record.
Enforcement
The strongest non-empty result determines the outcome:
| Mode | Effect |
|---|---|
advisory |
Findings are recorded; an applyable run may continue |
soft_mandatory |
Blocked until every current soft result has a valid override |
hard_mandatory |
Always blocked; no override exists |
Evaluation errors always block. A speculative or plan-only run can never ask the approval gate for apply authorization, even when every policy passes.
Soft overrides
A soft override requires policy:override_soft, a trimmed 1–2,000-character
justification, and an expiry no more than 24 hours away. It is immutable and
binds the actor and request ID to exactly one:
- run;
- plan digest;
- policy version; and
- policy result digest.
The gate re-reads those facts from PlanetScale. A changed plan, result, version, expired override, incomplete attachment snapshot, hard finding, or evaluation error fails closed. Every current soft result needs its own valid override.
POST /v1/runs/{run_id}/policy-overrides
Content-Type: application/json
{
"policy_result_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"justification": "Approved migration exception; removal tracked in INFRA-2214.",
"expires_at": "2026-07-27T18:00:00.000Z"
}Approval records store the database-derived policy evaluation ID and composition digest. Callers cannot substitute policy evidence in an approval request. The same gate must be checked again before issuing an apply-start grant.
Reading results
The native API returns only persisted, sanitized evidence:
GET /v1/runs/{run_id}/policyOpenTofu’s cloud protocol sees the same evaluation:
GET /api/v2/policy-checks/{policy_check_id}
GET /api/v2/policy-checks/{policy_check_id}/output
POST /api/v2/policy-checks/{policy_check_id}/actions/overrideThe upstream OpenTofu client sends the compatibility override action without a
request body. StackShip derives every current failing soft-result digest from
the persisted evaluation, records the fixed justification
OpenTofu CLI soft policy override, and gives the override a 15-minute
expiry. The caller still needs policy:override_soft.
This compatibility translation does not change dashboard or v1 API override semantics. Those surfaces still require the caller-supplied justification, expiry, and policy-result digest described above.
Run JSON:API documents link their persisted policy check. The output endpoint
streams bounded text findings with backpressure and never reads raw plan or
state content. Compatibility statuses are pending, passed, soft_failed,
hard_failed, errored, and overridden.
The successful plan commit reserves the run’s deterministic policy-check ID in
the same PlanetScale transaction as the immutable plan evidence. OpenTofu can
therefore follow the run relationship immediately and receives pending
instead of a transient 404 while the policy runner starts. The reserved row
has no input digest. Policy evaluation binds the exact sanitized-input digest
once before recording results; a replay with the same digest is idempotent and
a different digest fails closed.
If the parent run enters a terminal state before that reserved evaluation
commits complete evidence, StackShip leaves the authoritative evaluation row
in its incomplete running state with null result and composition digests.
The OpenTofu compatibility projection reports that check as errored and its
output explains that evaluation did not complete before the run terminated.
This keeps the cloud client from polling a pending child of a terminal run
forever without fabricating policy evidence or permitting apply.
Policy checks also include the Terraform Cloud-compatible scope that OpenTofu uses for its CLI heading. An evaluation containing only organization policy results is reported as an organization check; workspace and project policy results are reported as a workspace check because the Cloud run belongs to a workspace.
Operational limits
| Property | Limit or behavior |
|---|---|
| OPA | Exactly 1.18.2, Rego v1 |
| Entrypoint | Exactly data.stackship.v1.decision |
| Attachments per run | 256 |
| Sanitized input | 2 MiB and 50,000 nodes |
| Resource changes | 5,000 |
| Findings per attachment | 500 |
| One materialized result | 512 KiB |
| Complete result composition | 2 MiB |
| Policy source archive | 100 MB, streamed |
| Compiled policy bundle | 100 MB, streamed |
| Policy test results | 2 MiB, streamed |
| Policy build duration | 15-minute bootstrap and sandbox TTL |
| Network and host access | None |
| Sensitive values | Replaced with null; sensitivity maps retained |
| Duplicate effective set | Dispatch/evaluation fails closed |
| Evaluator or schema error | error; apply remains blocked |