---
title: "Policy sets"
description: "StackShip's deterministic Rego policy contract, immutable publication model, scoped attachments, persisted evaluation evidence, and digest-bound overrides."
---

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

# Policy sets

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-v1` and 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.

1. `POST /v1/policy-sets` creates the set.
2. `POST /v1/workspaces/{workspace_id}/policy-sets/{policy_set_id}/versions`
   streams a gzip-compressed tar archive into encrypted restricted storage and
   records a pending publication request for its committed digest.
3. A dedicated sandboxed policy-build attempt runs strict Rego validation,
   tests, and bundle creation with pinned OPA and capabilities.
4. 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:

```http
GET /v1/workspaces/{workspace_id}/policy-attachments
```

Create a workspace attachment with:

```http
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:

```http
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`:

```json
{
  "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:

```text
data.stackship.v1.decision
```

It returns one strict object:

```json
{
  "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.

```http
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:

```http
GET /v1/runs/{run_id}/policy
```

OpenTofu's cloud protocol sees the same evaluation:

```text
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/override
```

The 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                     |

## What to read next

- [Run lifecycle](/runs/lifecycle)
- [Approvals](/runs/approvals)
- [Audit log](/governance/audit)
- [v1 REST API](/automation/api)

Source: https://docs.stackship.run/governance/policies/index.mdx
