Skip to content

Policy sets

StackShip's deterministic Rego policy contract, immutable publication model, scoped attachments, persisted evaluation evidence, and digest-bound overrides.

Updated View as Markdown

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:

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

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

It 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}/policy

OpenTofu’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/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
Navigation

Type to search…

↑↓ navigate↵ selectEsc close