API Reference

September 18, 2026 · View on GitHub

Terrapod implements the subset of the TFE V2 API that the terraform/tofu cloud backend consumes (over the go-tfe protocol) — a stable contract mounted at /api/tfe/v2/ and catalogued in tfe-cli-surface.md. It is not a reimplementation of the full TFE V2 API: everything beyond that CLI-consumed slice is Terrapod's own native API at /api/v1/. All endpoints use JSON:API format.

The interactive API documentation is also available in the web UI under API in the navigation bar, offering both ReDoc and Swagger UI views.

API Documentation (ReDoc)


Consumers

The Terrapod API has four distinct consumer classes. Every API change must update every affected consumer — this is a hard contract:

  1. Web UI (web/) — Next.js BFF + React frontend. Server-side SSR + client-side fetch(). Frontend changes when JSON:API attribute names, endpoint paths, or response shapes change.
  2. go-terrapod (go-terrapod/) — the canonical Go SDK. Strongly-typed methods over the Terrapod JSON:API surface. Single source of truth for the Go view of the API: the provider and migration tool both import it; third-party Go automation can import it directly (github.com/mattrobinsonsre/terrapod/go-terrapod). Same shape and stability story as hashicorp/go-tfe.
  3. terraform-provider-terrapod (provider/) — a thin wrapper around go-terrapod. The provider holds only Terraform-plugin-framework code (schema, state translation, lifecycle hooks); every API call goes through go-terrapod. No JSON:API marshalling code lives inside provider/internal/.
  4. terrapod-migrate (migrate/) — the migration tool that moves TFE/HCP + Atlantis platforms onto Terrapod. Reads from the source via go-tfe (TFE migrations) or local-clone HCL parsing (Atlantis migrations). Writes to Terrapod via go-terrapod. Distributed as a universal-macOS + linux/windows amd64+arm64 GitHub Release artifact.

Workflow for extending the API

  1. Add the endpoint to the appropriate Python router (services/terrapod/api/routers/*.py).
  2. Add a typed method on go-terrapod (go-terrapod/<resource>.go) + tests.
  3. Update each consumer that needs it: the provider's resource file, the frontend page, or the migration tool's writer.

Endpoint coverage

go-terrapod targets the full Terrapod API surface — both the TFE-V2-compatible (/api/tfe/v2/) and Terrapod-native (/api/v1/) prefixes. The migration tool is a heavy consumer of the API-only routers (config-versions, state-management, registry endpoints) that the UI doesn't surface; the provider mostly consumes the frontend-also routers. Both can rely on the same typed surface in go-terrapod.

Version contract

go-terrapod pins to a specific Terrapod API version at build time via the SDKVersion constant and exposes Client.VersionCheck so a consumer can fail-fast on a version mismatch. Releases of Terrapod, go-terrapod, the provider, and the migration tool all happen at the same tag — they ship together, so keeping the CLIs and the server on matching versions avoids schema drift.


Base URL and Authentication

Base URL

Terrapod exposes two API surfaces, each with a deprecated alias:

PrefixContractAudience
/api/tfe/v2/Stable TFE V2 subset consumed by terraform, tofu, and tfci. Documented in docs/tfe-cli-surface.md.Terraform/OpenTofu CLI, tfci, go-tfe-based clients that stay within this subset
/api/v1/Terrapod-native management API (workspaces management, runs, registry CRUD, agent pools, audit, etc.)Web UI, the Terraform provider for Terrapod, automation
/api/terrapod/v1/Deprecated alias for the above — every route, identical responses. Kept for the support window; see deprecations.md.Existing integrations, and Terrapod's own runner/listener images until they are upgraded
/api/tfe/v2/Stable TFE V2 subset consumed by terraform, tofu and tfci. Named for what it is: a compatibility layer for another product's protocol, kept out of Terrapod's own version namespace.Terraform/OpenTofu CLI, tfci, go-tfe clients
/api/v2/Deprecated alias for the TFE surface — every route, identical responses. Advertised in service discovery until v1.7.0; still served.Existing clients, and runner images until upgraded

Example:

# CLI-contract (cloud-block, state, etc.)
https://terrapod.example.com/api/tfe/v2/organizations/default/workspaces

# Terrapod-native management
https://terrapod.example.com/api/v1/workspaces

A handful of endpoints (e.g. /health, /ready, /.well-known/terraform.json, OAuth flows, /v1/... registry CLI protocol) live at the root for protocol-compatibility reasons.

/api/terrapod/v1/ still works. It serves the same routes as /api/v1/, from the same code, so nothing breaks if you have not migrated. Prefer /api/v1/ in anything new. The one place the alias does not apply is the SSO callback URL, which your identity provider validates against its own allow-list — that is governed by auth.legacy_callback_url and explained in deprecations.md.

Authentication

Include a Bearer token in the Authorization header:

Authorization: Bearer <api-token-or-session-token>

API tokens are obtained via terraform login, the web UI, or the token creation endpoint. Session tokens are obtained via the login flow.

Content Type

Requests with a body should use:

Content-Type: application/vnd.api+json

Responses use application/json (accepted by go-tfe).

Organization

Terrapod is single-organization. The literal organization name default is the only valid value; every API path that contains an organization segment uses organizations/default/ verbatim. Requests to any other organization name return 404.

Pagination

List (collection GET) endpoints support optional pagination and always return a meta.pagination block:

Query paramMeaning
page[size]Items per page. >= 1 returns that page (capped at 100). 0 — or omitting paging entirely — returns the full list as a single page.
page[number]1-indexed page to return (default 1). Only meaningful with page[size] >= 1. A page past the end returns an empty data array, not an error.

Every list response carries pagination metadata in Terrapod's four-key shape:

{
  "data": [ /* ... */ ],
  "meta": {
    "pagination": {
      "current-page": 1,
      "page-size": 100,
      "total-count": 250,
      "total-pages": 3
    }
  }
}

Notes:

  • Absent params → full list. Omitting page[size] (or sending page[size]=0) returns every item, so a client that just wants the whole collection can ignore paging entirely. This is a deliberate Terrapod behaviour; on /api/tfe/v2/ the meta.pagination shape still matches what a go-tfe client expects.
  • To fetch everything robustly, page and loop rather than relying on the absent-params default — request page[size]=100, then page[number]=1,2,… until total-pages is reached. go-terrapod exposes ListAll* helpers that do this; the web UI uses fetchAllPages().
  • Unbounded-history collections are the one exception: the runs list (GET /api/tfe/v2/workspaces/{id}/runs) keeps a bounded default page (20) instead of returning all history, but still honours page[size] and emits meta.pagination so you can page the full history.
  • total-count is the size of the whole (RBAC-filtered) collection, not the returned page.

Health Check Endpoints

Liveness Probe

GET /health

Returns 200 if the API process is running.

Response:

{"status": "healthy"}

Readiness Probe

GET /ready

Checks database, Redis, and storage subsystems. Returns 200 if all healthy, 503 otherwise.

Response (healthy):

{
  "status": "ready",
  "checks": {
    "database": "healthy",
    "redis": "healthy",
    "storage": "healthy"
  }
}

Service Discovery

Terraform Service Discovery

GET /.well-known/terraform.json

Returns service discovery document for terraform login and registry protocol.

Response:

{
  "login.v1": {
    "client": "terraform-cli",
    "grant_types": ["authz_code"],
    "authz": "/oauth/authorize",
    "token": "/oauth/token",
    "ports": [10000, 10010]
  },
  "modules.v1": "/api/tfe/v2/registry/modules/",
  "providers.v1": "/api/tfe/v2/registry/providers/"
}

Ping

GET /api/tfe/v2/ping

API version handshake. Returns TFE-compatible version headers.

Response headers:

TFP-API-Version: 2.6
TFP-AppName: Terrapod
X-TFE-Version: v202301-1

Account

Current User Details

GET /api/tfe/v2/account/details

Returns the authenticated user's information.

Response:

{
  "data": {
    "id": "user-abc123",
    "type": "users",
    "attributes": {
      "username": "alice@example.com",
      "email": "alice@example.com",
      "is-service-account": false
    }
  }
}

Organizations

Show Organization

GET /api/v1/organizations/default

Returns organization details. Only default is valid.

Entitlement Set

GET /api/tfe/v2/organizations/default/entitlement-set

Returns feature flags (all enabled for Terrapod).


Workspaces

List Workspaces

GET /api/v1/workspaces                                # native — every enabled engine
GET /api/tfe/v2/organizations/default/workspaces      # TFE-compatible — Terraform only

Supports optional pagination (page[size]/page[number]; absent or page[size]=0 returns the full list) and filtering by search[name] and cloud-block tags (filter[tagged][...]). Results are scoped to workspaces the caller can read.

The native list also takes filter[engine] (terraform, pulumi, …). A workspace whose engine is turned off on this deployment is absent from it, not listed and refused; turning the engine back on brings it back unchanged. The TFE-compatible list never returns a non-Terraform workspace — a terraform CLI cannot use one.

Get Workspace by Name

GET /api/v1/workspaces/{name}                         # native — every enabled engine
GET /api/tfe/v2/organizations/default/workspaces/{name}

A Pulumi workspace is named project::stack and is read the same way (GET /api/v1/workspaces/proj::dev).

Get Workspace by ID

GET /api/v1/workspaces/{id}                           # native — every enabled engine
GET /api/tfe/v2/workspaces/{id}                       # TFE-compatible — Terraform only

The native read answers 404 both for a workspace that does not exist and for one the caller cannot read, so a lookup by name reveals nothing about names the caller has no access to.

List Engines

GET /api/v1/engines

The engines this deployment enables, as {"data": [{"type": "engines", "id": "pulumi", ...}, {"type": "engines", "id": "terraform", ...}]}. terraform is always present. An engine that is turned off is absent from the list, not listed and then refused. The web UI shows engine choices (the create form's picker and the list's engine filter) only when more than one engine is enabled, so a Terraform-only deployment shows no engine UI at all.

Create Workspace

POST /api/v1/workspaces                              # native — carries `engine`
POST /api/tfe/v2/organizations/default/workspaces    # TFE-compatible — Terraform only

Both accept the same attributes and return the same workspace. They differ in one respect: only the native route accepts an engine.

Workspaces are created here, in the UI, or with the Terraform provider — never by an engine's own CLI. terraform init has always looked a workspace up and failed if it was absent, and the same is true of pulumi stack init, which refuses and names this endpoint. A CLI that could create a workspace would be creating a platform resource with no RBAC review and no record of its origin.

engine — the execution engine family: terraform (the default when omitted) or another engine the deployment enables. Distinct from execution-backend, which picks the binary within the Terraform engine (tofu or terraform). An engine that is disabled is refused with 422, and the message lists the engines this deployment offers.

A Pulumi workspace is named project::stack, because that is what pulumi stack select default/{project}/{stack} resolves to; a single-part name is rejected rather than accepted and then never found by the CLI.

The TFE-compatible route is Terraform-only by design and ignores an engine attribute: it is the CLI compatibility contract, and a client there cannot see a workspace belonging to another engine.

Auto-apply modes (auto-apply-mode)

ModeApplies automatically when
neverNever — every plan waits for a human.
alwaysAlways, on any successful plan.
createThe plan only adds resources.
create_updateThe plan only adds resources and updates them in place.

create and create_update never auto-apply a plan that destroys or replaces a resource — that run stays planned for a human, and the run's auto-apply-declined-reason says why (for example 2 destroys, 1 replace).

Because those two modes need to know the shape of the plan, they are decided once the plan JSON has been uploaded — a moment later than always, which is decided as soon as the plan finishes. If the plan JSON never arrives, the run is left for a human rather than applied.

auto-apply is retained as the boolean projection: it reads true for any mode that auto-applies at all, and writing it still works (truealways, falsenever). Setting both auto-apply and auto-apply-mode in one request is rejected with 422 rather than guessing which was meant.

Runs carry the decision too: auto-apply-mode is the mode snapshotted when the run was created (editing the workspace mid-run cannot retroactively change how an in-flight run decides), and auto-apply-declined-reason says why a conditional mode refused this plan — for example 2 destroys, 1 replace. It is null unless the run was actually held.

The bulk-update endpoint (POST /api/v1/workspaces/actions/bulk-update) and the autodiscovery rule template both accept auto-apply-mode under the same either/or rule.

Request body:

{
  "data": {
    "type": "workspaces",
    "attributes": {
      "name": "my-workspace",
      "auto-apply": false,
      "auto-apply-mode": "create_update",
      "execution-mode": "agent",
      "engine": "terraform",
      "engine-version": "1.9.8",
      "resource-cpu": "1",
      "resource-memory": "2Gi",
      "labels": {
        "env": "dev",
        "team": "platform"
      },
      "vcs-repo-url": "https://github.com/org/repo",
      "vcs-branch": "main",
      "working-directory": "terraform/",
      "drift-detection-enabled": false,
      "drift-detection-interval-seconds": 86400
    },
    "relationships": {
      "vcs-connection": {
        "data": {
          "id": "vcs-abc123",
          "type": "vcs-connections"
        }
      }
    }
  }
}

Required permission: Any authenticated user can create workspaces (creator becomes owner).

engine-version, and its older name terraform-version

A workspace pins the version of whichever engine it runs — OpenTofu, Terraform or Pulumi — in one attribute, engine-version. That is the canonical name.

terraform-version is the same attribute under its original name, and it is permanent. It is not deprecated and there is no sunset date: it is go-tfe's own attribute name, so tofu, terraform and tfci send and read it on the TFE compatibility surface. Terrapod therefore keeps both names for good, exactly as it does for structured / hcl on variables.

On inputSend either. engine-version wins if you send both. Sending both with different values is a 422 rather than a silent precedence rule — a client disagreeing with itself about which version to run has a bug, and picking a winner would hide it.
On outputBoth are returned, always with the same value. Read whichever your client already knows about.
WhereWorkspaces, runs, autodiscovery rules, the bulk-update update body, and the workspace-search filter (which is snake_case: engine_version, with terraform_version accepted).

An empty string is a value, not an absence: it means "whatever this deployment's default is for that engine", resolved when the run starts. Omitting the attribute on create takes the deployment default instead.

Partial versions are supported and resolve to the newest matching release — "1.12" means 1.12.*. Do not use HCL constraint operators (~>, >=); those belong in a required_version block inside your configuration.

Agent pool set

A workspace routes its runs to a set of agent pools. The set is flat: a queued run is offered to every pool at once and whichever pool has a live listener claims it first. There is no primary, no ordering preference and no rotation — losing one pool simply means another claims the work.

Exactly-once execution is unaffected. A run is a single row claimed under SELECT … FOR UPDATE SKIP LOCKED, so offering it to N pools cannot produce N claims. On claim the run records the pool that took it, so cancellation, job-status queries and log streaming address the cluster actually running it.

Two attributes carry this, and they are mutually exclusive on write:

AttributeTypeMeaning
agent-pool-idslist of apool-…The whole set.
agent-pool-idapool-… | nullOn read, element 0 of the set. On write, "this workspace has exactly this one pool" — it replaces the set. null clears it.

Sending both in one request returns 422. agent-pool-name and the agent-pool relationship also resolve to element 0; an agent-pools to-many relationship carries the full set.

{
  "data": {
    "type": "workspaces",
    "attributes": {
      "agent-pool-ids": ["apool-019e01db-...", "apool-019e02ac-..."]
    }
  }
}

Required permission: workspace admin, plus pool:assign on every pool in the set — a caller may not attach a workspace to a pool they could not have attached it to on its own.

Upgrading from a single pool: agent-pool-id keeps working exactly as it did, so no client has to change. Be aware that because it replaces the set, a tool that manages agent_pool_id (an un-upgraded Terraform provider, say) will drop pools added elsewhere the next time it applies. Manage the set with agent-pool-ids if more than one tool touches it. The removal is audit-logged either way.

A workspace whose pools have all gone dark surfaces a no_live_agent_pool health condition (distinct from no_agent_pool, which means none is assigned at all) and is counted by the terrapod_workspaces_without_live_pool metric — the signal to alert on, since losing a single pool is now survivable.

Update Workspace

PATCH /api/v1/workspaces/{id-or-name}                 # native — every enabled engine
PATCH /api/tfe/v2/workspaces/{id}                     # TFE-compatible — Terraform only

Same body format as create. Only include attributes to change. Both routes run the same validation. A rename follows the workspace's own engine: a Pulumi workspace keeps the project::stack shape.

Required permission: admin on the workspace.

Self-lockout protection: If the request changes labels and the new labels would reduce the caller's own access level, the API returns 409 Conflict with a descriptive error. Re-submit with "force": true in the attributes to confirm the change. Platform admins and workspace owners are immune (their access doesn't depend on labels).

Workspace Permissions Block

All workspace responses (show and list) include a permissions object reflecting the authenticated user's effective permissions:

{
  "permissions": {
    "can-update": true,
    "can-destroy": true,
    "can-queue-run": true,
    "can-queue-apply": true,
    "can-queue-destroy": true,
    "can-read-state-versions": true,
    "can-create-state-versions": true,
    "can-read-variable": true,
    "can-update-variable": true,
    "can-lock": true,
    "can-unlock": true,
    "can-force-unlock": true,
    "can-read-settings": true
  }
}

### Delete Workspace

DELETE /api/v1/workspaces/{id}


**Deleting does not delete the state.** The state blobs stay in object storage
and a delete marker records what they belonged to, so a mistaken delete is
recoverable for
`api.config.artifact_retention.deleted_workspace_retention_days` (default 30)
— see [Deleted Workspaces (undelete)](#deleted-workspaces-undelete) and
[the runbook](runbooks.md#i-deleted-a-workspace-by-mistake). After that window
the reaper removes the state permanently and it is unrecoverable.

Recovery produces a **new workspace with a new id**, so anything referencing the
old id needs repointing — it is a salvage operation, not an undo.


**Required permission:** `admin` on the workspace.

### Lock Workspace

POST /api/tfe/v2/workspaces/{id}/actions/lock


**Required permission:** `plan` on the workspace.

A manual lock is the CLI/UI state lock **and** an operator gate on applies: while a workspace is locked, apply-capable (plan+apply) runs **will not start** and a confirm (`POST /api/tfe/v2/runs/{id}/actions/apply`) returns **409 Conflict**. Auto-apply runs settle in `planned` and wait for an unlock rather than applying. **Plan-only runs (speculative plans, drift checks) are not blocked** — they never mutate state. Returns 409 if the workspace is already locked; the existing lock, its reason and its holder are left untouched.

The body is optional. A reason given in it is stored with the lock, together with the caller's identity (the user's email), so an operator can see why the workspace is locked and who locked it. Two body shapes are accepted:

```json
{ "reason": "maintenance window — network cutover" }

The WorkspaceLockOptions shape go-tfe sends. The UI and API clients use it.

{ "ID": "3f0c6a2e-…", "Operation": "OperationTypeApply", "Info": "", "Who": "ops@laptop", "Version": "1.9.0" }

The state lock-info object the terraform/tofu cloud/remote backend sends. The reason is Info when it is non-empty, otherwise Operation. ID, when present, becomes the lock ID as before.

Blank or non-string values are ignored, and a reason is truncated to 1000 characters. The response is the workspace, with the read-only attributes set:

AttributeTypeDescription
lock-reasonstring | nullWhy the workspace is locked, as given when the lock was taken. null when unlocked or when no reason was given.
locked-bystring | nullThe identity that took the lock (a user's email). null when unlocked, or when the lock was taken by something with no user identity (a Pulumi update's lock carries the reason pulumi update and no holder).

Both attributes appear on every workspace response, and are null whenever locked is false.

Unlock Workspace

POST /api/tfe/v2/workspaces/{id}/actions/unlock

Required permission: plan on the workspace (own locks only).

Unlocking clears lock-reason and locked-by along with the lock.

Force-Unlock Workspace

POST /api/tfe/v2/workspaces/{id}/actions/force-unlock

Required permission: admin on the workspace (the workspace:force-unlock capability).

Clears the state lock regardless of the lock ID — the endpoint terraform/tofu force-unlock calls. Use it to release a lock held by another user or a lock stranded when a CLI operation crashed mid-run. Idempotent: force-unlocking an already-unlocked workspace returns 200. The lock's reason and holder are cleared with it.

Drift Detection Attributes

Workspaces support the following drift detection attributes (settable on create and update):

AttributeTypeDefaultDescription
drift-detection-enabledbooleantrue (VCS) / false (non-VCS)Enable or disable automatic drift detection. Auto-enabled when a VCS connection is set
drift-detection-interval-secondsinteger86400How often to run drift detection checks (minimum: 3600 seconds / 1 hour)
plan-expiry-secondsinteger / nullnullPer-workspace plan-expiry TTL (#646). When set, an apply-capable run that has sat in planned longer than this (from plan completion) is auto-discarded and must be re-planned. null / 0 = disabled (default)
drift-ignore-ruleslist[string][]Glob-aware patterns silenced by the drift-result classifier (#482). Each rule is a Terraform address optionally suffixed with a dotted attribute path; * matches zero or more non-. chars (spans [N] indices), [*] matches any bracketed index. A bare address with no attribute suffix silences any change to that resource — including destroys — so use carefully. Max 50 entries, ≤ 500 chars each. Examples: aws_iam_role.foo.tags.Environment, aws_autoscaling_group.workers[*].desired_capacity, module.eks*.argocd_cluster.*.config.tls_client_config.ca_data. Affects drift-detection runs only — regular plan/apply is untouched. See drift-ignore-rules.md for the full grammar and recipes
security-scan-enforcementstringadvisoryIaC security-scan (Checkov/Trivy) enforcement (#1036): off (skip the stage), advisory (scan and record findings, never block apply — the default), or enforced (a failed/errored scan holds the run in planning until the finding is fixed or a workspace admin overrides it)
security-scan-enginestringcheckovWhich scanner(s) run: checkov, trivy, or both (union of findings, deduped)
security-scan-severity-thresholdstringhighLowest finding severity that counts as a scan failure: critical, high, medium, or low
security-scan-skip-ruleslist[string][]Scanner rule-ids to suppress (Checkov CKV_* / Trivy AVD-*). Max 200 entries, ≤ 100 chars each

Terragrunt Attributes

Workspaces support running agent-mode plans/applies through Terragrunt (settable on create and update). See terragrunt.md for the full feature description, including the CLI-driven path that needs no configuration.

AttributeTypeDefaultDescription
terragrunt-enabledbooleanfalseWrap tofu/terraform with Terragrunt for agent-mode runs. The runner fetches the terragrunt binary from the pull-through binary cache, pins it to the workspace's execution backend via TG_TF_PATH, and reconciles the backend to local so Terrapod still owns state
terragrunt-versionstring1.0Terragrunt CLI version. Partial versions (e.g. 1.0) are resolved by the binary cache to the latest matching release; pin an exact x.y.z for reproducibility

AI Plan Summary Attributes

Workspaces carry two attributes that govern the optional AI plan-summary feature (settable on create and update). When the feature is globally disabled at the deployment level (api.config.ai_summary.enabled: false), these fields are stored but inert — no calls are made. See docs/ai-plan-summary.md for the full operator guide.

AttributeTypeDefaultDescription
ai-summary-modestring"default"Per-workspace override. One of "default" (follow the global toggle), "enabled" (always summarise this workspace's plans), or "disabled" (never summarise this workspace — overrides global).
ai-summary-contextstring""Free text up to 4000 characters appended to the model's prompt as workspace-specific facts (e.g. "Fronts the vault for service X — destroying the KMS key causes a global outage."). Additive to the deployment-wide fleet_context.
slack-channelstring""Opt-in Slack channel (name or ID) this workspace's run notifications post to (#556) — approval requests, applies, errors, drift. Empty = silent (there is no deployment-wide fan-out). Only effective when the Slack app is enabled server-side (api.config.slack.enabled). See slack-integration.md.

422 errors:

  • ai-summary-mode outside the enum
  • ai-summary-context longer than 4000 characters
  • ai-summary-context not a string

The following read-only attributes are included in workspace responses when drift detection is enabled:

AttributeTypeDescription
drift-last-checked-atstring (RFC3339) or nullTimestamp of the last completed drift detection check
drift-statusstringCurrent drift status: "" (never checked), "no_drift", "drifted", or "errored"
drift-latest-run-idstring (run-…) or nullID of the drift run that produced the current drift-status. Lets the workspace-list UI link the badge straight to the run that explains the status. Cleared on a successful (non-drift) apply because the previous drift run is no longer the canonical link. Null when drift detection has never run or when the column predates v0.35.3

VCS Polling Health Attributes

Read-only, present on every workspace. The pair is what makes a stalled VCS connection detectable from the API alone — vcs-last-polled-at advances only on a successful poll, so on its own a frozen value cannot be told apart from a workspace that is simply not due yet.

AttributeTypeDescription
vcs-last-polled-atstring (RFC3339) or nullTimestamp of the last successful poll. Null if no poll has ever succeeded
vcs-last-attempted-atstring (RFC3339) or nullTimestamp of the last poll attempt, successful or not. Stamped before anything in the poll can fail. Null on workspaces not polled since the upgrade that added it
vcs-last-errorstring or nullThe most recent poll failure, named as specifically as the provider allows — a rate limit reports the HTTP status and, when the response carries the headers, how long until the window resets. Cleared on the next successful poll
vcs-last-error-atstring (RFC3339) or nullTimestamp of vcs-last-error

To alert on a stalled connection, compare the two timestamps rather than relying on vcs-last-error being populated: vcs-last-attempted-at newer than vcs-last-polled-at means the most recent attempt did not succeed.

Lifecycle Attributes (Autodiscovery)

Workspaces created by autodiscovery carry read-only lifecycle attributes that track rename/delete/orphan reconciliation. They are included in workspace responses and surfaced by the UI as a banner on the workspace detail page and a badge on the workspace list.

AttributeTypeDescription
lifecycle-statestring"active" (normal), "pending_deletion" (the source directory was removed or the workspace was orphaned and the rule did not opt in to destroy — needs an explicit operator action), or "archived" (terminal: never-applied orphan auto-archived, or destroyed via an opt-in destroy rule, or a superseded speculative rename duplicate)
lifecycle-reasonstringHuman-readable explanation of the current state (e.g. directory 'accounts/x' removed on 'main', origin PR #14 closed unmerged; never applied — auto-archived). Empty for active workspaces
autodiscovery-pr-numberinteger or nullThe PR that created the workspace, while it is still speculative. Cleared (graduated) once that PR merges; null for non-autodiscovered workspaces

The owning autodiscovery rule's on-directory-delete policy (flag default, or opt-in destroy) governs what happens when a tracked directory is removed — see autodiscovery.md. A rename of a tracked directory moves the existing workspace in place (state and history preserved); it never destroys, even on a destroy rule.

State Divergence Flag

Workspaces also expose a read-only state-diverged boolean. It is set to true when the runner reports it could not upload state after a successful apply (the apply ran against the real provider, but the corresponding state version did not land in Terrapod), so Terrapod's view of the workspace state and the real-world infrastructure may have drifted. The UI surfaces this as a banner on the workspace detail page. Clear it by running a fresh apply or by manually uploading a state version that matches reality.

"state-diverged": false

List VCS Refs (Terrapod Extension)

GET /api/v1/workspaces/{id}/vcs-refs

Returns branches, tags, and the default branch for a VCS-connected workspace. Used by the UI to populate the VCS ref picker when queueing runs.

Required permission: read on the workspace.

Response:

{
  "branches": [
    {"name": "main", "sha": "abc123..."},
    {"name": "feature-x", "sha": "def456..."}
  ],
  "tags": [
    {"name": "v1.0.0", "sha": "789abc..."}
  ],
  "default-branch": "main"
}

Returns 422 if the workspace is not VCS-connected or the VCS connection is inactive.


State Versions

List State Versions

GET /api/tfe/v2/workspaces/{id}/state-versions

Required permission: read on the workspace.

Current State Version

GET /api/tfe/v2/workspaces/{id}/current-state-version

Required permission: read on the workspace.

For agent-mode runs reading another workspace's state via data "terraform_remote_state" (runner-token principals), authorization is by the producer-controlled consumer allowlist instead of per-user RBAC — see Cross-Workspace Remote-State Consumers and the composition guide.

Create State Version

POST /api/tfe/v2/workspaces/{id}/state-versions

Request body:

{
  "data": {
    "type": "state-versions",
    "attributes": {
      "serial": 1,
      "md5": "d41d8cd98f00b204e9800998ecf8427e",
      "lineage": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    }
  }
}

Required permission: write on the workspace.

Show State Version

GET /api/tfe/v2/state-versions/{id}

Download State

GET /api/tfe/v2/state-versions/{id}/download

Returns a redirect to a presigned URL for the raw state file.

Required permission: plan on the workspace.

For agent-mode runs reading another workspace's state via data "terraform_remote_state" (runner-token principals), authorization is by the producer-controlled consumer allowlist instead of per-user RBAC — see Cross-Workspace Remote-State Consumers.

Upload State Content

PUT /api/tfe/v2/state-versions/{id}/content

Binary upload of raw state bytes. No auth required (presigned-style -- the state version UUID acts as a capability token). This matches go-tfe behavior.

Upload JSON State Content

PUT /api/tfe/v2/state-versions/{id}/json-content

Accepted and discarded (placeholder for future use).

State Version Response Format

State version responses include a created-by attribute (email of the user who created it, or null for runner-created states) and a run relationship linking to the run that produced the state:

{
  "data": {
    "id": "sv-...",
    "type": "state-versions",
    "attributes": {
      "serial": 1,
      "lineage": "...",
      "md5": "...",
      "size": 1234,
      "created-at": "2026-01-01T00:00:00Z",
      "created-by": "user@example.com"
    },
    "relationships": {
      "run": {
        "data": { "id": "run-...", "type": "runs" }
      }
    }
  }
}

Delete State Version (Terrapod Extension)

DELETE /api/v1/state-versions/{id}/manage

Deletes a non-current state version. The current (highest serial) version cannot be deleted.

Required permission: admin on the workspace.

Returns 204 on success, 409 if attempting to delete the current version.

Rollback State Version (Terrapod Extension)

POST /api/v1/state-versions/{id}/actions/rollback

Creates a new state version with the content of the specified older version. The new version gets serial = max existing + 1. This is a "copy forward" rollback — no versions are deleted, history is preserved. The serial inside the copied state file is set to the new version's serial, so the next apply counts on from it rather than from the old version and does not collide with an existing serial.

Required permission: write on the workspace.

Returns 201 with the new state version.

Upload State Manually (Terrapod Extension)

POST /api/v1/workspaces/{id}/state-versions/actions/upload

Upload a raw state JSON file. Serial is auto-assigned (max existing + 1). Useful for state surgery workflows.

Required permission: write on the workspace.

Request body: Raw state JSON (Content-Type: application/json).

Returns 201 with the new state version.


Runs

Create Run

POST /api/tfe/v2/runs

Request body:

{
  "data": {
    "type": "runs",
    "attributes": {
      "message": "Triggered from API",
      "engine": "terraform",
      "is-destroy": false,
      "auto-apply": false,
      "plan-only": false,
      "target-addrs": ["aws_instance.web"],
      "replace-addrs": [],
      "refresh-only": false,
      "refresh": true,
      "allow-empty-apply": false
    },
    "relationships": {
      "workspace": {
        "data": {
          "id": "ws-abc123",
          "type": "workspaces"
        }
      }
    }
  }
}

Required permission: plan for plan-only runs, write for apply runs.

Run concurrency: serialization & auto-discard

Apply-capable (plan+apply) runs are serialized per workspace — only one executes at a time. When a newer apply-capable run is queued for a workspace:

  • Older un-applied apply-capable runs are auto-discarded so a stale plan can't later apply outdated config: a planned run awaiting confirmation transitions to discarded, and pending/queued runs transition to canceled (message: Superseded by run <id>).
  • An in-flight run (confirmed/applying) is never superseded — committed applies finish on their own terms; the newer run waits until it reaches a terminal state.

Plan-only runs are exempt — speculative PR plans, CLI plan, and drift checks never mutate state, so they run concurrently and neither supersede nor are superseded. (Speculative PR runs are still superseded per-PR by the VCS poller on a new commit.)

This is enforced server-side regardless of run source (VCS, CLI/API, UI), so the same guarantees hold for terraform/tofu CLI-driven runs as for VCS-driven runs.

Runs held at a post-plan gate: blocked-by

A mandatory policy set, an enforced security scan, or a mandatory post-plan run task can stop a run after its plan has finished. The run stays at status: planning, and the read-only blocked-by attribute names the gate holding it: run-task, policy or security-scan (checked in that order), or null for any run not held. While held:

  • the run's plan reports status: finished, so a CLI waiting on the plan log returns;
  • the run is discardable (actions.is-discardable: true) unless it is plan-only, and a newer apply-capable run supersedes it as it would a planned run;
  • it is released by an override (policy or security scan) or a passing run task, which the reconciler picks up on its next tick. Kubernetes cleaning up the finished plan Job does not error it.

In the Terraform Enterprise vocabulary (#1704) — api.config.runs.tfe_post_plan_decisions: true, the default from 2.0, or the request header X-Terrapod-Post-Plan-Decisions: tfe — a held run instead reports status as post_plan_running (post-plan tasks still running), post_plan_awaiting_decision (a mandatory task failed) or policy_override (a mandatory policy set or an enforced scan failed); blocked-by is unchanged. The policy-checks and task-stages relationships then carry data (the run's policy checks and task stages), and GET /api/tfe/v2/runs/{id}?include=task_stages returns the stages and their task-results in included. X-Terrapod-Post-Plan-Decisions: legacy asks for the 1.x answer. See post-plan-decisions.md.

Stale-plan guards: state drift (#647) & expiry (#646)

Beyond supersede (a newer run case), two guards protect against applying a plan that no longer reflects reality. Both resolve an apply-capable planned run to discarded and surface the reason in the run's discard-reason attribute; confirming a stale plan returns 409 (re-plan required). Plan-only / drift / speculative runs are exempt.

  • State-version drift (#647, always on) — a plan is snapshotted against the workspace's state serial when it starts. If the current state serial advances before the plan is applied — another apply, a CLI state push, a rollback, a manual upload — the plan is stale and is auto-discarded (discard-reason: state changed since plan (serial N -> M)). This runs even in agent mode, where the server drives the apply and there is no client to catch it. A first apply (no prior state) has no baseline and is never stale.
  • Time-based expiry (#646, per-workspace, off by default) — when a workspace sets plan-expiry-seconds, a plan older than that TTL (from completion) is auto-discarded by a periodic sweep and at confirm time (discard-reason: plan expired after {ttl}s).

Whichever reason fires first wins, and both compose with supersede.

Configuration version resolution

The configuration-version relationship is optional. Resolution rules:

  • Explicit CV in the request body → used as-is.
  • No CV + workspace has a VCS connection → Terrapod fetches the latest commit (or the branch/tag from vcs-ref) and creates a fresh CV automatically.
  • No CV + non-VCS workspace → falls back to the latest fully-uploaded, non-speculative CV for the workspace. This is the path the UI's "Queue Plan" button takes.
  • No CV + non-VCS workspace + no upload has ever succeeded422 Unprocessable Entity with detail "Workspace has no uploaded configuration. Upload one via 'tofu plan' / 'tofu apply' (CLI), or POST a configuration version + tarball before queueing a run.". The same response covers the misconfigured-workspace edge case where vcs_connection_id is set but vcs_repo_url is empty.

The CLI plan/apply flow always supplies a CV (it uploads one first), so it's unaffected by the fallback.

Optional Run Attributes

AttributeTypeDefaultDescription
target-addrsarray of strings[]Resource addresses to target (equivalent to -target CLI flag)
replace-addrsarray of strings[]Resource addresses to force replacement (equivalent to -replace CLI flag, plan phase only)
refresh-onlybooleanfalseRefresh-only plan — reconcile state without planning changes (equivalent to -refresh-only)
refreshbooleantrueWhether to refresh state before planning. Set to false to skip refresh (equivalent to -refresh=false)
allow-empty-applybooleanfalseAllow apply even when the plan has no changes (equivalent to -allow-empty-apply)
vcs-refstring""Branch, tag, or SHA to fetch code from instead of the workspace's tracked branch. Only valid on VCS-connected workspaces. Runs with a non-default ref are always plan-only — the server enforces this regardless of the plan-only attribute value

Run Response Attributes (Drift Detection)

Run objects include the following drift detection attributes in responses:

AttributeTypeDescription
is-drift-detectionbooleantrue if the run was created by the drift detection scheduler
has-changesboolean or nullWhether the plan detected infrastructure changes. null if the plan has not completed yet
pulumi-bind-planboolean or nullWhether this run's update performs exactly the operations its preview showed (#1553). null for any engine but Pulumi, which has no such distinction

Drift detection runs are always plan-only and are not counted in the workspace's normal run queue.

Run Response Attributes (Resource Profile / OOM)

Run objects include peak resource usage + an abnormal-exit signal so the UI (and external clients) can surface memory pressure without operators having to grep pod logs. All five attributes are null / "" for runs that pre-date the feature or never started a Job.

AttributeTypeSourceDescription
resource-cpustringWorkspace setting (snapshot)CPU request applied to the Job (K8s quantity, e.g. "1", "500m"). Limit is this
parallelismintegerWorkspace setting (snapshot)Concurrent operations the engine performs — -parallelism for terraform/tofu. Default 10, minimum 1, maximum 256
pulumi-bind-planbooleanWorkspace settingPulumi workspaces only (#1553): save the preview's plan and bind the update to it (preview --save-plan / up --plan). Default false — the preview saves nothing and the update is a plain pulumi up. Off by default because Pulumi's update plans are still experimental upstream. Refused (422) with true on any other engine
resource-memorystringWorkspace setting (snapshot)Memory request applied to the Job (K8s quantity, e.g. "2Gi"). Limit is this
peak-memory-bytesinteger or nullRunner (cgroup v2)Peak resident memory observed during the run — /sys/fs/cgroup/memory.peak
peak-cpu-usecinteger or nullRunner (cgroup v2)Cumulative CPU time consumed by the run, microseconds — usage_usec from /sys/fs/cgroup/cpu.stat. Captured but not surfaced in the UI — see note below
runner-exit-codeinteger or nullRunnerRunner script's exit code captured at exit. null if the trap didn't fire (e.g. SIGKILL)
runner-exit-reasonstringListener (K8s)Raw K8s container.state.terminated.reason (e.g. "OOMKilled", "Error", "Completed"). "" if not observed
runner-exit-statusstringReconciler (typed bucket)Stable typed value: "" (unknown / not yet observed), "clean", "oom", "killed", "error". The UI keys on this; reason is shown for context

Two independent capture paths feed these fields:

  • Runner pathPOST /api/v1/runs/{run_id}/resource-profile from the runner's EXIT trap with peak_memory_bytes / peak_cpu_usec / exit_code. Fires for any catchable exit (success, plan errored, OPA failed, SIGTERM during apply).
  • Listener path — when a Job fails, the listener reads container.state.terminated.{reason, exit_code} and POSTs them on the job-status report. The reconciler maps them to runner_exit_status.

OOM (exit 137 + reason "OOMKilled") is uncatchable, so the runner path never fires on OOM — the listener path is the only signal. Both paths converge on the same five DB columns; whichever signal arrives wins. runner-exit-status is set only by the reconciler (single source of truth for typed bucketing) and is what drives the UI's OOM badge + the typed error message ("Runner OOM-killed (peak memory N.NN Gi). Workspace resource_memory is …. Increase resource_memory + retry.").

See runners.md — Memory Pressure & OOM Visibility for the operator-facing tuning workflow.

Run Response Attributes (Agent Pool)

Which agent pool has a run, and which pools could have claimed it. With multi-pool workspace routing (#1085) a queued run is offered to every pool in the workspace's set at once and whichever has a live listener claims it first — so "where did this actually execute?" is not answerable from the workspace's configuration alone.

AttributeTypeDescription
agent-pool-idstring or nullThe pool that has the run. At creation this is element 0 of the workspace's pool set; on claim it is rewritten to the pool that actually took the run, so on a finished run it is where the run executed. null for local-execution runs, and for agent runs whose pool has since been deleted
candidate-agent-pool-idsarray of stringEvery pool that could have claimed the run, snapshotted at run creation and preserved (only re-ordered, winner first) across the claim

The agent-pool relationship carries the same id as agent-pool-id and is the canonical link form; the attribute is kept alongside it.

Note the difference from a workspace. On a workspace, agent-pool-id is always a projection of agent-pool-ids[0] and carries no routing preference. On a run it is the claimant. Likewise candidate-agent-pool-ids is deliberately named apart from the workspace's agent-pool-ids: the workspace attribute is the live configured set, while the run attribute is a point-in-time snapshot that does not move when the workspace is later re-pointed at different pools.

Show Run

GET /api/tfe/v2/runs/{run_id}

List Workspace Runs

GET /api/tfe/v2/workspaces/{id}/runs

Newest first. Because run history grows without bound, this endpoint keeps a bounded default page (page[size] default 20, max 100) rather than returning everything — the one exception to the "absent → full list" pagination convention. It honours page[size]/page[number] and always returns meta.pagination, so you can page the full history.

Confirm Run (Approve Apply)

POST /api/tfe/v2/runs/{run_id}/actions/apply

Required permission: write on the workspace.

Discard Run

POST /api/tfe/v2/runs/{run_id}/actions/discard

Required permission: write on the workspace.

Cancel Run

POST /api/tfe/v2/runs/{run_id}/actions/cancel

Required permission: write on the workspace.

Retry Run

POST /api/v1/runs/{run_id}/actions/retry

Creates a new run from a terminal run (applied, errored, canceled, discarded) using the same workspace, configuration version, VCS metadata, and settings. A plan-only run left at planned can also be retried. The new run keeps the original's kind: a retried plan-only run is plan-only, and a retried destroy run is still a destroy. Returns a 409 if the run is not in a retryable state.

Required permission: what queuing that run would need — run:plan for a plan-only run, run:apply for an apply run, run:apply-destroy for a destroy run (the workspace permissions block reports these as can-queue-run, can-queue-apply and can-queue-destroy).

Workspace Events (SSE)

GET /api/v1/workspaces/{workspace_id}/runs/events

Server-Sent Events stream for real-time workspace updates. The stream emits events whenever a run changes state, the workspace is locked/unlocked, workspace settings are updated, or a new state version is created. Used by the web UI workspace detail page for live updates without polling.

Event types:

EventTrigger
run_status_changeRun transitions to a new state
workspace_lock_changeWorkspace is locked or unlocked (includes locked boolean)
workspace_updatedWorkspace settings are modified
state_version_createdNew state version is uploaded
workspace_variable_changeA workspace variable is created, updated, or deleted
workspace_notification_changeA notification configuration is created, updated, or deleted
workspace_run_task_changeA run task is created, updated, or deleted
run_trigger_changeA run trigger to/from this workspace is added or removed (published to both the source and destination workspace channels, so inbound edges appear live)
remote_state_consumer_changeA remote-state consumer grant to/from this workspace is added or removed (published to both the producer and consumer channels)

The stream sends : keepalive comments every ~1 second. Events are JSON-encoded in data: fields. The web UI workspace detail page re-fetches the active tab on receipt of its corresponding event.

Required permission: read on the workspace.

Workspace List Events (SSE) (Terrapod Extension)

GET /api/v1/workspace-events

Server-Sent Events stream for the workspace list page. Emits events whenever any workspace changes (run status, lock, settings, state). The web UI uses this to refresh the workspace list without polling.

Required permission: Any authenticated user.

Plan Details

GET /api/v1/runs/{run_id}/plan

Returns plan metadata and log download URL. When the runner has uploaded a structured plan (-out=tfplanterraform show -json tfplan), the response also carries a json-output attribute pointing at /api/tfe/v2/plans/{run_id}/json-output.

A Pulumi run uploads a preview digest to the same place (#1560), built from the engine events its preview writes:

{
  "engine": "pulumi",
  "change_summary": {"create": 2, "update": 1, "same": 9},
  "has_changes": true,
  "steps": [{"op": "create", "urn": "urn:pulumi:dev::shop::aws:s3/bucket:Bucket::assets",
             "type": "aws:s3/bucket:Bucket"}],
  "steps_truncated": false
}

The digest deliberately carries each step's operation, URN and type and nothing else — the engine's events also hold every resource's old and new state, which is where a stack's secrets are. steps is capped (steps_truncated says when), while change_summary is exact however large the preview. Both shapes fill the same resource-additions / resource-changes / resource-destructions / resource-replacements / resource-imports attributes, so the change badges, conditional auto-apply and the AI summary read one shape whatever produced it. Pulumi's replace fills resource-replacements; its paired create-replacement and delete-replaced are not counted again.

Plan JSON Output

GET /api/tfe/v2/plans/{plan_id}/json-output

Returns the structured JSON representation of the plan, as produced by terraform show -json tfplan. Useful for downstream tooling that wants to consume the resource changes without parsing the human-readable log. Responds 302 to a presigned object-storage URL.

The endpoint is mounted at /api/tfe/v2/ because go-tfe and Terraform's cloud block expect it there. Returns 404 if the runner never uploaded the JSON output (older runs, runs that errored before the plan completed).

Impact Graph

GET /api/v1/runs/{run_id}/impact-graph

Returns a compact plan dependency + blast-radius graph derived server-side from the run's stored JSON plan output — the data behind the run page's Impact graph tab. Nodes are the resources in the plan (coloured by planned action: create / update / replace / delete / no-op); edges are the dependencies between them, reconstructed by walking the plan's configuration module tree with cross-module var/output binding (so edges span module boundaries, and per-instance for_each fan-out is captured). Each node carries its module path so the UI can cluster and label by module.

Deriving the graph on the server (rather than shipping the raw, possibly multi-MB plan JSON to the browser) keeps the payload small and works uniformly through the BFF in every storage backend — unlike json-output, whose presigned redirect isn't browser-reachable with the filesystem backend.

Response: {"data": {"type": "impact-graphs", "attributes": {"nodes": [...], "edges": [...], "meta": {"terraform_version": "...", "counts": {...}}}}}

Required permission: read on the workspace. Returns 404 when the run produced no JSON plan output.

Security Scan (#1036)

The deterministic Checkov/Trivy IaC security-scan result for a run — the structural twin of the OPA policy endpoints. See security-scanning.md for the feature guide; the per-workspace config attributes (security-scan-*) are documented in the workspace attributes table above.

GET  /api/v1/runs/{run_id}/security-scan                     # read the result (workspace read)
POST /api/v1/runs/{run_id}/actions/override-security-scan    # override a blocking scan (workspace admin)

GET returns {"data": <resource>|null, "meta": {"summary": {...}}}. data is null when the workspace has scanning off or the run wasn't scanned; for a run whose engine is never scanned (Pulumi, until #1569), meta.not-evaluated-reason says why. The resource attributes are: engine, enforcement-level, severity-threshold, outcome (passed/failed/errored), findings (list of {engine, rule_id, severity, title, resource, file, line, guideline}), summary ({total, blocking, by_severity, …}), error, overridden-by, overridden-at, created-at. meta.summary carries a compact {status, outcome, engine, total, blocking} for the badge (status = blocked | advisory-failed | passed). Requires read on the workspace.

POST override marks a failed/errored result overridden and, when the run is still held in planning by an enforced scan, re-drives it immediately (mirrors the policy override). Requires admin on the workspace; audit-logged. Prefer fixing the finding or adding a skip rule.

The runner protocol (runner-token, run_id-scoped) — GET .../security-scan-config and POST .../security-scan-results — is internal to the runner and mirrors the OPA policy-bundle/policy-results pair; the enforcement level and severity threshold are re-resolved server-side from the workspace on results POST, never trusted from the runner body.

Estate Graph

GET /api/v1/estate-graph

Returns the whole-estate topology graph behind the Estate topology page: every workspace the caller can read (nodes of kind workspace, carrying their raw labels, pool, and in-degree) plus the registry module nodes they use, wired by three edge kinds — remote-state (consumer → producer), run-trigger (source → destination), and uses-module (module → workspace).

The result is RBAC-filtered: only workspaces the caller can read appear, and an edge or module is included only when it touches a visible workspace — so the estate view never discloses a workspace (or a dependency on one) the caller couldn't otherwise see. The response is deliberately label-agnostic (each workspace ships its raw labels); the grouping axis is chosen client-side, because the platform enforces no labelling convention.

Response: {"data": {"type": "estate-graphs", "attributes": {"nodes": [...], "edges": [...], "meta": {"counts": {"workspaces": N, "modules": N, "edges": N}}}}}

Required permission: any authenticated user (content is filtered by per-workspace read access).

State Graph

GET /api/v1/workspaces/{workspace_id}/state-graph[?state_version=sv-...]

Returns the single-workspace resource dependency graph behind the State Resource Graph tab: one resource node per resource address in the workspace's Terraform state (each carrying type, mode (managed/data), module, provider, and in-degree), wired by depends-on edges derived from the dependencies Terraform records per resource. Defaults to the workspace's current (highest-serial) state version; ?state_version=sv-... renders an older one.

meta.versions lists every state version (for the picker) and meta.state_version names the one rendered. Very large states are capped at meta.max_nodes (2,000); meta.truncated and meta.total_resources report this honestly. A workspace with no state (or a version whose content was never uploaded) returns an empty graph, not an error.

Response: {"data": {"type": "state-graphs", "attributes": {"nodes": [...], "edges": [...], "meta": {"counts": {"resources": N, "edges": N}, "truncated": false, "total_resources": N, "max_nodes": 2000, "versions": [...], "state_version": {...}}}}}

Required permission: state:read on the workspace (the graph is derived from the secret-bearing state blob, so it requires the same access as downloading raw state).

AI Architecture Critique (Terrapod Extension)

State-based, whole-system critique (#1036 Part 2). Reviews the workspace's deployed system as it exists — inferred from its current Terraform state (+ the resource graph, the deterministic cost estimate, and the deterministic security-scan findings) and critiqued across resilience / security / cost / well-architected. Distinct from the per-run Plan Summary, which reviews a change. Enabled by the independent ai_architecture config (off by default).

GET  /api/v1/workspaces/{workspace_id}/architecture-critique
POST /api/v1/workspaces/{workspace_id}/architecture-critique/regenerate

GET returns the critique for the workspace's current state version: {"data": {"type": "architecture-critiques", "attributes": {"status": "ready|pending|skipped|errored", "risk-level": "low|medium|high|critical", "architecture": {...}, "findings": [{"severity", "category", "title", "detail", "resource-address"|"resource_address", "recommendation", "grounded_in"}], "deferred": [...], "state-serial": N, ...}}}. Returns 404 when the feature is disabled, the workspace has no state, or no critique has been generated for the current state yet. POST .../regenerate queues a fresh critique (202) and mutates no infrastructure.

Required permission: state:read on the workspace (the critique reasons over the secret-bearing state, so it requires the same access as downloading raw state).

SSE: progress rides the existing per-workspace run-events channel as architecture_critique_pending / architecture_critique_ready / architecture_critique_skipped / architecture_critique_errored.

Auto-generation: a critique is enqueued automatically when a new state version's content is uploaded (after an apply, or a manual state push), best-effort and idempotent per state serial — so the current state always has an up-to-date critique without a manual trigger. POST .../regenerate forces a fresh one.

Consumers: go-terrapod (GetArchitectureCritique / RegenerateArchitectureCritique), the MCP tool terrapod_workspace_architecture_critique (read; inherits state:read), the Terraform provider data source terrapod_architecture_critique, and the workspace Architecture tab in the web UI. See architecture-critique.md.

Plan Summary

GET /api/v1/runs/{run_id}/plan-summary

Returns the AI-generated plan summary (or failure analysis on errored plans) when the optional ai_summary feature is enabled and a summary has been produced for the run. See docs/ai-plan-summary.md for the operator-side setup.

Required permission: read on the workspace.

plan_id accepts either plan-{uuid} (the canonical form, matching what's returned in the plans relationship of a Run) or a bare run UUID.

Responses:

  • 200 OK — the workspace has a summary row for this run. See response shape below.
  • 404 Not Found — no summary row exists. Either the feature is globally disabled, the workspace opted out (ai-summary-mode: disabled), or the summariser hasn't run yet. The UI treats 404 as "no AI surface" and renders nothing.

Response shape:

{
  "data": {
    "id": "plan-summary-<uuid>",
    "type": "plan-summaries",
    "attributes": {
      "kind": "plan_summary",
      "status": "ready",
      "description": "Adds a single root-level output named `marker` ...",
      "risk-level": "low",
      "risk-factors": [
        {
          "severity": "low",
          "title": "Output-only addition",
          "detail": "The plan only introduces the `marker` output ...",
          "resource_address": "output.marker"
        }
      ],
      "model": "bedrock/us.anthropic.claude-opus-4-8",
      "input-tokens": 1335,
      "output-tokens": 171,
      "error-message": "",
      "created-at": "2026-06-01T12:00:00Z",
      "updated-at": "2026-06-01T12:00:30Z"
    },
    "relationships": {
      "plan": { "data": { "id": "plan-<uuid>", "type": "plans" } },
      "run":  { "data": { "id": "run-<uuid>",  "type": "runs"  } }
    }
  }
}

Attribute reference:

AttributeTypeDescription
kindstring"plan_summary" (successful plan → change description + risk assessment) or "failure_analysis" (plan-phase errored → root-cause + suggested fixes).
statusstring"pending" (handler running), "ready" (model returned a parseable response), "skipped" (workspace disabled or daily budget hit — see error-message), or "errored" (model call failed — see error-message).
descriptionstringMarkdown body. For kind=plan_summary, ~600 words describing the proposed changes. For kind=failure_analysis, root-cause explanation.
risk-levelstringOne of "low", "medium", "high", "critical". Reflects blast radius + reversibility, not novelty.
risk-factorsarray of objectEach item carries severity (same enum as risk-level), title (max 120 chars), detail (max 600 chars), optional resource_address (terraform address), and optional category (one of security / reliability / cost / operations / scalability / change / other). The category tag is present on the grounded design-review factors that the summary additionally surfaces when the workspace has security scanning and/or cost estimation enabled (Checkov/Trivy findings are the ground truth for security, the cost estimate for cost); the classic change-risk factors leave it empty. For kind=failure_analysis these are suggested fixes ordered most-likely-to-resolve first.
modelstringLiteLLM model string used for this summary (e.g. bedrock/us.anthropic.claude-opus-4-8).
input-tokens / output-tokensintegerTelemetry counts reported by the upstream provider.
error-messagestringPopulated only for status=errored or status=skipped. Empty for ready.

Real-time updates: the per-workspace SSE channel (GET /api/v1/workspaces/{id}/runs/events) emits one of five lifecycle events as the summary progresses (#463):

EventFires when
plan_summary_pendingHandler dispatched (or operator clicked Regenerate). UI shows a placeholder.
plan_summary_readyInitial summary landed; refetch to render.
plan_summary_erroredHandler/model failure; refetch to render the error.
plan_summary_skippedRunner died abnormally / workspace opted out / daily budget hit.
plan_summary_message_postedA chat follow-up turn landed (carries message_id). Refetch the transcript.

All five payloads carry {run_id, workspace_id} at minimum. The UI re-fetches the summary on any of them. For VCS-driven runs, the per-workspace PR/MR status comment is edited in place to include the summary content when it lands.

The AI cost estimate (#871 — the optional AI layer over the data-only cost estimate, riding this same ai_summary.enabled switch + per-workspace mode) emits its own lifecycle events on the same per-workspace channel: cost_summary_pending, cost_summary_ready, cost_summary_errored, cost_summary_skipped (workspace opted out / daily budget hit / no estimate), and cost_summary_message_posted (a cost-chat follow-up turn landed — carries message_id; refetch the transcript). Each carries {run_id, workspace_id}. Its primary output is estimates for resources the pricesheet couldn't price; the authoritative figures stay on the data-only cost-estimate endpoint, and every AI dollar amount is tagged source: "ai-estimate", never summed into the deterministic total.

When the workspace has security scanning and/or cost estimation enabled, the same plan summary additionally emits grounded design-review risk factors — ordinary risk-factors[] items tagged with a category (security / reliability / cost / operations / scalability / change / other), with the deterministic Checkov/Trivy findings as the ground truth for security and the cost estimate as the ground truth for cost. This is additive: with those signals off, the summary behaves exactly as before, and there is no separate endpoint or SSE event — it is one AI analysis per run, delivered through the existing plan_summary_* lifecycle above.

Regenerate Plan Summary

POST /api/v1/runs/{run_id}/plan-summary/regenerate

Re-fires the AI summary handler for a run. Anyone with workspace read can regenerate — the call doesn't mutate infrastructure. Bypasses the 5-minute auto-dedup so operator clicks always go through; budget gating still applies handler-side.

Required permission: read on the workspace.

Responses:

  • 202 Accepted — pending row upserted and trigger enqueued. Response shape matches the GET above with status=pending.
  • 409 Conflict — run is in a state with no summarisable output yet (still planning, or apply-phase errored).
  • 503 Service Unavailable — AI summary is globally disabled (api.config.ai_summary.enabled: false).

List Plan-Summary Chat Messages

GET /api/v1/runs/{run_id}/plan-summary/messages

Full transcript of the AI plan-summary chat thread in chronological order. The initial structured summary lives on the parent PlanSummary row (description + risk-factors); this endpoint returns ONLY the conversational follow-ups. The UI renders message[0] from the parent summary and appends these.

Required permission: read on the workspace.

Responses:

  • 200 OK with an array (possibly empty) of plan-summary-messages resources.
  • 404 Not Found — no initial summary exists for this run.
  • 409 Conflict — the initial summary is still pending or errored. Can't chat against an unready summary.
{
  "data": [
    {
      "id": "plan-summary-message-<uuid>",
      "type": "plan-summary-messages",
      "attributes": {
        "role": "user",
        "content": "How long will the RDS update take?",
        "model": "",
        "input-tokens": 0,
        "output-tokens": 0,
        "error-message": "",
        "created-at": "2026-06-01T12:01:00Z"
      }
    },
    {
      "id": "plan-summary-message-<uuid>",
      "type": "plan-summary-messages",
      "attributes": {
        "role": "assistant",
        "content": "An in-place RDS modify with `apply_immediately = false` typically completes during the next maintenance window…",
        "model": "bedrock/us.anthropic.claude-sonnet-4-6",
        "input-tokens": 14823,
        "output-tokens": 412,
        "error-message": "",
        "created-at": "2026-06-01T12:01:14Z"
      }
    }
  ],
  "meta": { "count": 2 }
}

Post Plan-Summary Chat Message

POST /api/v1/runs/{run_id}/plan-summary/messages
Content-Type: application/vnd.api+json

{ "data": { "attributes": { "content": "..." } } }

Posts a user follow-up + returns the synchronous assistant reply. Authorisation is read-on-workspace — anyone who can see the run can chat in the thread (GitHub PR conversation semantics, not per-user threads).

Required permission: read on the workspace.

Responses:

  • 201 Created — the response body is the assistant turn (same shape as the GET list entries). The persisted user turn is visible via the next GET call.
  • 400 Bad Request — empty body or body > 32 KiB.
  • 409 Conflict — initial summary not ready, or this run already has followup_max_messages_per_run user turns. The user-turn counter is server-tracked, not advisory.
  • 429 Too Many Requests — daily AI token budget exhausted.
  • 503 Service Unavailable — chat globally disabled (followup_max_messages_per_run: 0) or workspace opted out.
  • 502 Bad Gateway — model HTTP / parse failure. The user turn is still persisted in the transcript, and a separate errored assistant row is recorded — so a reload shows the failure cleanly.

The model call uses the same cacheable prefix as the initial summary (provider prompt caching serves the prefix hit). See docs/ai-plan-summary.md#follow-up-chat-463 for the operator-side caps + provider matrix.

Apply Details

GET /api/v1/runs/{run_id}/apply

Returns apply metadata and log download URL.


Cross-Workspace Remote-State Consumers

Producer-controlled allowlist of workspaces authorized to read this workspace's state via data "terraform_remote_state". Default is empty (not shared) — secure by default. All mutations require admin/write on the producer (the state owner). Independent of run triggers — see the composition guide.

When a runner-token principal (agent-mode run) hits /api/tfe/v2/workspaces/{id}/current-state-version or /api/tfe/v2/state-versions/{id}/download for another workspace, authorization is by this allowlist instead of per-user RBAC. User / API-token principals (CLI, UI, automation) continue through the existing per-user RBAC path.

List Consumers

GET /api/v1/workspaces/{id}/remote-state-consumers?filter[remote-state-consumer][type]=outbound

outbound (default) — workspaces this workspace shares its state to (this workspace is the producer). inbound — workspaces whose state this workspace is authorized to read (this workspace is the consumer).

Required permission: read on the workspace.

Authorize a Consumer

POST /api/v1/workspaces/{producer_id}/remote-state-consumers

Request body:

{
  "data": {
    "relationships": {
      "consumer": {"data": {"id": "ws-CONSUMER", "type": "workspaces"}}
    }
  }
}

Required permission: admin on the producer workspace. A consumer team cannot self-grant.

Errors: 422 self-reference; 409 already authorized; 422 over the per-producer cap.

Replace Consumer Set (Declarative)

PUT /api/v1/workspaces/{producer_id}/remote-state-consumers

Idempotent declarative replace of the producer's full consumer set in one atomic transaction. Supports the Terrapod provider's set-valued attribute. Body: {"data": [{"type": "workspaces", "id": "ws-..."}, ...]}.

Required permission: admin on the producer.

Show Consumer Grant

GET /api/v1/remote-state-consumers/{id}

Required permission: read on the producer.

Revoke a Consumer Grant

DELETE /api/v1/remote-state-consumers/{id}

Required permission: admin on the producer. A consumer cannot self-revoke.

Consumer Grant Response Attributes

AttributeDescription
producer-workspace-nameName of the producer workspace
consumer-workspace-nameName of the consumer workspace
created-atRFC3339 timestamp the grant was created
created-byIdentity that created the grant

Plus relationships producer and consumer (both workspaces-typed).


Run Triggers

Run triggers create cross-workspace dependency chains. When a source workspace completes an apply, all downstream workspaces with an inbound trigger automatically get a new run queued.

Create Run Trigger

POST /api/v1/workspaces/{id}/run-triggers

Request body:

{
  "data": {
    "relationships": {
      "sourceable": {
        "data": {
          "id": "ws-source-workspace-id",
          "type": "workspaces"
        }
      }
    }
  }
}

Required permission: admin on the destination workspace.

Validation:

  • Source and destination must be different workspaces
  • No duplicate triggers for the same pair
  • Maximum 20 source workspaces per destination

Example:

curl -s \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/vnd.api+json" \
  -X POST \
  https://terrapod.example.com/api/v1/workspaces/ws-abc123/run-triggers \
  -d '{
    "data": {
      "relationships": {
        "sourceable": {
          "data": {"id": "ws-def456", "type": "workspaces"}
        }
      }
    }
  }'

List Run Triggers

GET /api/v1/workspaces/{id}/run-triggers?filter[run-trigger][type]=inbound|outbound
  • inbound: triggers where this workspace is the destination (what triggers runs here?)
  • outbound: triggers where this workspace is the source (what does my apply trigger?)

The filter[run-trigger][type] parameter is required (422 if missing).

Required permission: read on the workspace.

Example:

curl -s \
  -H "Authorization: Bearer $TOKEN" \
  "https://terrapod.example.com/api/v1/workspaces/ws-abc123/run-triggers?filter[run-trigger][type]=inbound"

Show Run Trigger

GET /api/v1/run-triggers/{id}

Required permission: read on the destination workspace.

Delete Run Trigger

DELETE /api/v1/run-triggers/{id}

Required permission: admin on the destination workspace.

Example:

curl -s \
  -H "Authorization: Bearer $TOKEN" \
  -X DELETE \
  https://terrapod.example.com/api/v1/run-triggers/rt-abc123

Configuration Versions

A workspace's uploaded source archives are also browsable from the UI — workspace detail → Configurations tab. The list highlights the current configuration with a current badge and supports download + side-by-side diff between any two versions.

Workspace Configurations

Create Configuration Version

POST /api/tfe/v2/workspaces/{id}/configuration-versions

Request body:

{
  "data": {
    "type": "configuration-versions",
    "attributes": {
      "auto-queue-runs": true
    }
  }
}

Response includes: upload-url attribute with a presigned URL for uploading the tarball.

Required permission: write on the workspace.

Upload Configuration

PUT <upload-url>
Content-Type: application/octet-stream

<tarball bytes>

No auth required (presigned URL).

Show Configuration Version

GET /api/tfe/v2/configuration-versions/{cv_id}

Returns a single CV's metadata.

List Configuration Versions

GET /api/tfe/v2/workspaces/{id}/configuration-versions

Newest first. Supports page[size] (default 20, max 100) and page[number].

Response includes meta.current-id — the CV id consumed by the most recent successful apply, or null if none. The UI uses this to badge the current row.

Required permission: read on the workspace.

Download Configuration Version (Terrapod extension)

GET /api/v1/configuration-versions/{cv_id}/download

Streams the tarball bytes back as application/x-tar with a Content-Disposition: attachment header. Bearer auth.

Status codes:

  • 200 — streaming tarball
  • 404 — CV doesn't exist or caller lacks read
  • 409 — CV exists but bytes haven't been uploaded yet
  • 410 — CV row exists but tarball was swept by retention

Required permission: read on the owning workspace.

Mint Download Ticket (Terrapod extension)

POST /api/v1/configuration-versions/{cv_id}/download-ticket

Mints a short-lived, single-resource HMAC ticket the browser can paste into a plain <a href> to stream a download natively to the user's save dialog. Opt-in — the default download path above is the simple Bearer-auth flow; tickets exist because plain navigation can't carry an Authorization header.

Request body (optional):

{"data": {"attributes": {"ttl-seconds": 300}}}

TTL defaults to 300 s, hard-capped at 1800 s. Negative or zero values fall back to the default.

Response:

{
  "data": {
    "type": "download-tickets",
    "attributes": {
      "ticket": "dlticket:cv:{uuid}:...",
      "url": "/api/v1/configuration-versions/download-by-ticket/dlticket:cv:...",
      "expires-at": "2026-05-07T12:34:56Z"
    }
  }
}

Required permission: read on the owning workspace (same gate as the direct download).

Download by Ticket (Terrapod extension)

GET /api/v1/configuration-versions/download-by-ticket/{ticket}

Streams the tarball — no Authorization header. The ticket is the auth: HMAC-SHA256 over the resource id, expiry, and minter email, signed with the same key class as runner tokens. Single-resource (a CV-X ticket cannot fetch CV-Y); short TTL bounds replay.

Status codes:

  • 200 — streaming tarball
  • 401 — malformed, expired, or bad-signature ticket
  • 410 — CV bytes swept by retention since mint

Diff Configuration Versions (Terrapod extension)

POST /api/v1/configuration-versions/diff

Compares two CVs in the same workspace and returns per-file unified diffs.

Request body:

{
  "data": {
    "attributes": {
      "from-id": "cv-...",
      "to-id":   "cv-..."
    }
  }
}

Response shape:

{
  "data": {
    "type": "configuration-version-diffs",
    "attributes": {
      "from-id": "cv-...",
      "to-id":   "cv-...",
      "files": [
        {"path": "main.tf", "type": "modified", "diff": "@@ ... @@"},
        {"path": "vars.tf", "type": "added",    "diff": "..."},
        {"path": "old.tf",  "type": "removed",  "diff": "..."},
        {"path": "logo.png","type": "binary-changed"}
      ],
      "oversized": ["modules/big.zip"],
      "total-files-changed": 4
    }
  }
}

Limits: per-file 1 MiB (oversized files report only their path), per-pair 32 MiB total (refused with 413 if exceeded). Binary files (NUL byte in first 8 KiB) report only binary-changed.

Status codes:

  • 200 — diff returned
  • 404 — either CV missing or caller lacks read
  • 409 — either CV not yet uploaded
  • 410 — bytes swept by retention
  • 413 — combined size exceeds the per-pair cap
  • 422 — missing ids or cross-workspace request

Required permission: read on the workspace (both CVs must belong to the same workspace).


Labels

Read-only labels browser (Terrapod extension). All endpoints are RBAC-filtered: results only include labels carried by entities the caller has at least read on for that entity's permission model. Editing labels still happens on each entity's own edit page — there is no labels-admin surface.

List Label Keys

GET /api/v1/labels

Returns all label keys in use across readable workspaces, modules, providers, and pools, with per-type counts.

Response shape:

{
  "data": [
    {"key": "team", "value-count": 4, "by-type": {"workspaces": 12, "modules": 2, "providers": 0, "pools": 1}}
  ]
}

List Values for a Key

GET /api/v1/labels/{key}

Returns distinct values for key, each with per-type counts. Empty data is a valid response.

List Entities for a Label

GET /api/v1/labels/{key}/{value}

Returns entities tagged with exactly key=value, grouped by type.

Response shape:

{
  "data": {
    "workspaces": [{"id": "ws-...", "name": "..."}],
    "modules":    [{"id": "mod-...", "name": "...", "provider": "..."}],
    "providers":  [{"id": "prov-...", "namespace": "...", "name": "..."}],
    "pools":      [{"id": "pool-...", "name": "..."}]
  }
}

Variables

List Workspace Variables

GET /api/tfe/v2/workspaces/{id}/vars

Required permission: read on the workspace. Sensitive values are never returned.

Create Variable

POST /api/tfe/v2/workspaces/{id}/vars

Request body:

{
  "data": {
    "type": "vars",
    "attributes": {
      "key": "AWS_REGION",
      "value": "eu-west-1",
      "category": "env",
      "sensitive": false,
      "description": "AWS region for provider"
    }
  }
}

category is one of terraform, env, git_http_auth, or git_ssh_auth. In agent mode all are delivered to the runner Job via a per-run Kubernetes Secret (never plaintext in the Job spec): terraform vars are rendered into a generated terrapod.auto.tfvars from a Secret-mounted blob (honouring structured), and env vars are injected via secretKeyRef. (In local execution mode the CLI handles variables itself.) The two git_*_auth categories carry credentials for private git module sources — the key is a host/URL pattern and the value a JSON credential; they are always forced sensitive and consumed by the runner's git-auth phase before init (see Module Source Auth), not by terraform/tofu directly.

structured marks a value as a typed expression rather than a plain string — for a terraform variable, a raw HCL expression (list, object, number, bool) rather than a quoted string. hcl is the same flag under its original name and is accepted and returned indefinitely, because tfci and go-tfe send and read it; responses carry both keys and they always agree. Supplying both with different values is a 422 rather than a silent precedence rule — a client that disagrees with itself about whether a value is typed has a bug worth surfacing.

Required permission: write on the workspace.

Update Variable

PATCH /api/tfe/v2/workspaces/{id}/vars/{var_id}

Delete Variable

DELETE /api/tfe/v2/workspaces/{id}/vars/{var_id}

Required permission: write on the workspace.


Variable Sets

List Variable Sets

GET /api/tfe/v2/organizations/default/varsets

Create Variable Set

POST /api/tfe/v2/organizations/default/varsets

Required permission: Platform admin.

Variable Set Variables

GET    /api/tfe/v2/varsets/{varset_id}/relationships/vars
POST   /api/tfe/v2/varsets/{varset_id}/relationships/vars
PATCH  /api/tfe/v2/varsets/{varset_id}/relationships/vars/{var_id}
DELETE /api/tfe/v2/varsets/{varset_id}/relationships/vars/{var_id}

Variable Set Workspace Assignments

POST   /api/tfe/v2/varsets/{varset_id}/relationships/workspaces
DELETE /api/tfe/v2/varsets/{varset_id}/relationships/workspaces

Required permission: Platform admin.

OpenBao/Vault Value Source

A variable's value-source is static (the default — value is the literal) or vault, where value holds a JSON reference to a secret in OpenBao (or HashiCorp Vault), resolved at run time. The value source is named vault for both servers:

{
  "data": {
    "type": "vars",
    "attributes": {
      "key": "NETBOX_TOKEN",
      "category": "env",
      "value-source": "vault",
      "value": "{\"mount\":\"secret\",\"path\":\"apps/netbox\",\"field\":\"apitoken\"}"
    }
  }
}

The reference takes mount, path and field; optionally vault (which configured instance), engine (kv2 default, or dynamic), method (GET default, or POST) and data (a body for POST engines). The path omits kv-v2's data/ segment — Terrapod adds it.

An optional file object delivers the value as a file instead: "file": {"name": "gcp/adc.json"} writes it to /var/run/terrapod/files/gcp/adc.json, and "file": {"name": "~/.aws/credentials"} to /home/runner/.aws/credentials. name defaults to the variable key. The variable's delivered value becomes the file's absolute path, for env and terraform variables alike. A name must be a relative path of [A-Za-z0-9._-] segments (no ., .. or empty segment, at most 255 characters), and a ~/ name may not target a path the runner manages.

The file's content is exactly one of:

KeyContent
field (on the reference)That field. With "file": {"encoding": "base64"} it is base64-decoded first; the result must be UTF-8 text.
file.templateA logic-less template over the secret, at most 16 KiB: {{ name }}, {{ map.key }}, filters json, base64decode, trim, lines, indent N, and {{ _lease.ttl }} / _lease.renewable / _lease.expires_at when the response has a lease. No field.
file.formatjson (the whole data map) or env (KEY="value" lines); file.fields: [...] selects a subset. No field.

file is refused with 422 together with structured (or its alias hcl), on a static value source, with any unknown key (mode is reserved), when more than one of field, file.template and file.format is given, for a template syntax error or an unknown filter, for fields without format, and for encoding with a template or format. An unknown template name, invalid base64, non-UTF-8 decoded bytes, two variables at one path, a rendered file over 256 KiB, or OpenBao/Vault files totalling over 768 KiB in one run error the run. Variables naming the same secret share one read per run, so fields of one dynamic credential always match — including every field a template uses. Details: Delivering as a file, Templates, formats and encoding.

A vault-sourced variable is always sensitive, but the API returns its value rather than masking it: the stored value is a path, not a secret. The secret it points at is resolved per run and never persisted, returned or logged. A malformed reference is rejected at write time with 422; one that cannot be resolved fails the run rather than delivering nothing.

Applies to variable-set variables too, so an OpenBao/Vault-backed credential can be defined once and applied to many workspaces.

GET /api/v1/vault/availability

Reports whether the value source is configured and which instances exist, so a client can offer it only where it will work. Returns instance names only — never addresses, namespaces or auth configuration. Any authenticated user may call it, since anyone who can write a variable needs to pick an instance.

OpenBao/Vault diagnostics

GET /api/terrapod/v1/admin/vault

The sampled status of each configured instance. It needs platform admin or audit. It is read from a sample the vault_status scheduler task takes every 60 seconds, never by contacting the server on the request. A collection of vault-instance-statuses, one per configured instance, with id set to the instance name:

{"data": [{"id": "default", "type": "vault-instance-statuses", "attributes": {
   "name": "default", "default": true, "address": "https://openbao:8200", "namespace": "",
   "auth-method": "kubernetes", "auth-mount": "kubernetes", "auth-role": "terrapod",
   "tls-trust": "instance-ca", "reachable": true, "initialized": true, "sealed": false,
   "standby": false, "version": "1.18.0", "health-error": null,
   "login-ok": true, "login-error": null, "ttl-seconds": 1740,
   "checked-at": "2026-09-15T10:00:00Z",
   "last-error": {"class": "VaultDenied", "at": "2026-09-15T09:12:03Z",
     "message": "variable 'NETBOX_TOKEN': OpenBao/Vault denied 'secret/apps/netbox' on instance 'default'. …"}}}],
 "meta": {"pagination": {},
   "vault": {"enabled": true, "sampled-at": "2026-09-15T10:00:00Z", "unavailable-reason": null}}}

tls-trust is instance-ca, global-bundle, default or skip-verify. Every probe field is null when unknown (not sampled yet, or not attempted: a sealed server is never logged in to), which is not the same as false. With the value source off, data is empty and meta.vault.enabled is false. unavailable-reason is not sampled yet or cache unreachable when there is no usable sample.

POST /api/terrapod/v1/workspaces/{workspace_id}/vault-reference-checks
POST /api/terrapod/v1/varsets/{varset_id}/vault-reference-checks

Checks a reference without resolving it. The workspace form needs var:write on the workspace; the variable-set form needs platform admin. Limited to 20 checks a minute per user (429 with Retry-After). The body carries reference (a reference object, or its JSON string) or variable-id (a stored vault-sourced variable), plus an optional key, which a file name defaults to:

{"data": {"type": "vault-reference-checks",
  "attributes": {"reference": {"mount": "secret", "path": "apps/netbox", "field": "apitoken"}}}}

A reference that does not parse is a 200 result with parses: false, not an error. 404 means an unknown workspace, variable set or variable; 422 means a body with neither reference nor variable-id, or a variable whose source is not vault. Nothing is stored, and the id only tells two answers apart:

{"data": {"id": "vrc-…", "type": "vault-reference-checks", "attributes": {
   "ok": false, "vault-enabled": true, "parses": true, "parse-error": null,
   "instance": "default", "instance-known": true, "engine": "kv2",
   "read-path": "secret/data/apps/netbox", "path-allowed": true,
   "readable": true, "capabilities": ["read", "list"], "required-capabilities": ["read"],
   "keys": ["apitoken", "url"], "fields-present": true, "missing-fields": [],
   "notes": [],
   "checks": [{"name": "parses", "status": "pass", "detail": ""},
              {"name": "instance", "status": "pass", "detail": ""},
              {"name": "path-allowed", "status": "pass", "detail": ""},
              {"name": "readable", "status": "pass", "detail": ""},
              {"name": "fields-present", "status": "pass", "detail": ""}]}}}

checks runs in order and stops at the first fail. A step's status is pass, fail, skipped or unknown, where unknown means the server could not answer. readable comes from sys/capabilities-self, which reads nothing at the path. keys holds key names, for kv-v2 only; it is null for a dynamic engine, which a check never reads because a read mints a credential, and null for a caller without run:plan. notes holds codes: dynamic-not-read, keys-need-plan-permission, local-execution and vault-disabled. See OpenBao/Vault → Diagnostics.

Full setup, including the server-side policy and role: OpenBao/Vault.

Assignment Rules

A variable set can select workspaces by their attributes instead of being bound to each one by hand. Set assignment-rule on create or update:

{
  "data": {
    "attributes": {
      "name": "prod-credentials",
      "assignment-rule": { "labels": { "env": "prod" } }
    }
  }
}

The rule accepts the workspace bulk-update selector's attribute dimensions -- labels, name_prefix, name_glob, execution_backend, execution_mode, terraform_version, agent_pool_id, vcs_connection_id, owner_email, drift_status, locked, has_vcs -- all AND-combined. Membership is re-evaluated on every run, so a workspace that later matches picks the set up without being touched, and one that stops matching stops receiving it.

Rejected with 422: an unparseable rule; a non-object rule; all: true (use global, which already means every workspace); workspace_ids (that is explicit assignment, and would produce a rule whose membership never re-evaluates); a rule on a set that is already global; and a rule whose dimensions are all blank -- {"name_prefix": ""} selects nothing meaningful and would otherwise match every workspace.

A rule that no longer parses matches nothing rather than everything, so a filter dimension removed in a later version cannot silently widen a scoped credential set to the whole estate.

Association Views

Read-only views of which workspaces a set reaches, and which sets reach a workspace. Both report how each association arose -- explicit, global, or rule -- because only an explicit one can be unbound.

GET /api/v1/varsets/{varset_id}/relationships/workspaces
GET /api/v1/workspaces/{workspace_id}/varsets

workspace-count on the variable set itself counts only explicitly-assigned workspaces; for a global or rule-based set it is not the whole answer, and these views are.

Required permission: platform admin for the varset-side view; workspace:read for the workspace-side view.


Registry -- Modules

CLI Protocol (for terraform init)

GET /api/tfe/v2/registry/modules/{namespace}/{name}/{provider}/versions
GET /api/tfe/v2/registry/modules/{namespace}/{name}/{provider}/{version}/download

Terrapod-native Management API

GET  /api/v1/registry-modules
POST /api/v1/registry-modules
GET  /api/v1/registry-modules/private/default/{name}/{provider}
DELETE /api/v1/registry-modules/private/default/{name}/{provider}
PUT  /api/v1/registry-modules/private/default/{name}/{provider}/versions/{version}/upload
DELETE /api/v1/registry-modules/private/default/{name}/{provider}/versions/{version}

Submodules. Create, PATCH …/{name}/{provider} and PATCH …/{name}/{provider}/vcs accept an optional subdirectory: the path within the module's repository to publish it from, for a submodule (see Submodules). It needs a vcs-repo-url; a path with .., . or empty segments is refused with 422, and a repository subdirectory that is already registered with 409. Modules report it as the subdirectory attribute, "" for a module at the repository root. On PATCH …/vcs, omitting it leaves it unchanged; removing the repository clears it.

Autodiscovery. To find the modules in a repository — the root and any submodules — and register them in bulk, use Module Autodiscovery Rules.

Module Version Interface

GET /api/v1/registry-modules/private/default/{name}/{provider}/{version}/interface

Returns the version's extracted inputs[] and outputs[], and interface-error: null when the interface was read, otherwise a short, display-safe reason it could not be (a corrupt archive, or each root .tf file that failed to parse). When it is set, empty or partial inputs do not mean the module declares no variables. Each entry of a module's version-statuses carries the same interface-error. See When the interface cannot be read. Required permission: read on the module. 404 when interface extraction is disabled or the version does not exist.

Update Module

PATCH /api/v1/registry-modules/private/default/{name}/{provider}

Required permission: admin on the module.

Self-lockout protection: If the request changes labels and the new labels would reduce the caller's own access level, the API returns 409 Conflict. Re-submit with "force": true in the attributes to confirm.

Module Permissions Block

All module responses (show and list) include a permissions object:

{
  "permissions": {
    "can-update": true,
    "can-destroy": true,
    "can-create-version": true
  }
}

Version Upload (Streamed)

PUT /api/v1/registry-modules/private/default/{name}/{provider}/versions/{version}/upload

A single streamed PUT of the gzipped module source tarball. The version is created implicitly on upload — there is no separate create step and no presigned URL. The server extracts the module interface (inputs and outputs) — the response's interface-error attribute is null when that succeeded and a short reason when it did not — and triggers impact runs on any linked workspaces (see Module Impact Analysis and the workspace-links section below).

Required permission: write on the module (the owner has admin).

Tooling: the terrapod-publish CLI packages the source directory and performs this upload.

Removed in the client-signed model: the previous POST .../versions create-then-upload-to-presigned-URL flow has been removed in favour of the single streamed PUT above.

GET    /api/v1/registry-modules/private/default/{name}/{provider}/workspace-links
POST   /api/v1/registry-modules/private/default/{name}/{provider}/workspace-links
DELETE /api/v1/registry-modules/private/default/{name}/{provider}/workspace-links/{link_id}

Required permission: admin on the module (create/delete), read on the module (list).

Terraform provider resource: terrapod_module_workspace_link


Registry -- Providers

CLI Protocol (for terraform init)

GET /api/tfe/v2/registry/providers/{namespace}/{type}/versions
GET /api/tfe/v2/registry/providers/{namespace}/{type}/{version}/download/{os}/{arch}

The download response advertises the publisher's own GPG public key in signing_keys.gpg_public_keys. Terrapod never re-signs a provider — the signature terraform init verifies is the one the publisher produced at publish time (see Publishing a Version).

Terrapod-native Management API

GET  /api/v1/registry-providers
POST /api/v1/registry-providers
GET  /api/v1/registry-providers/private/default/{name}
DELETE /api/v1/registry-providers/private/default/{name}
GET  /api/v1/registry-providers/private/default/{name}/versions
DELETE /api/v1/registry-providers/private/default/{name}/versions/{version}

Publishing a Version (Client-Signed)

Provider publishing is client-signed, direct, and streamed. The publisher computes SHA256SUMS over the platform zips and GPG-signs it with its own key; the server verifies that signature against a registered GPG public key and never re-signs. The version is created implicitly on the first upload — there is no separate create-version or finalize step.

Uploads must happen in this exact order:

PUT /api/v1/registry-providers/private/default/{name}/versions/{version}/shasums
PUT /api/v1/registry-providers/private/default/{name}/versions/{version}/shasums.sig
PUT /api/v1/registry-providers/private/default/{name}/versions/{version}/platforms/{os}/{arch}
  1. PUT .../shasums — the raw SHA256SUMS manifest (one {sha} {zipname} line per platform).
  2. PUT .../shasums.sig — the detached GPG signature over the manifest. The server verifies it against a registered GPG key here (the trust gate). Returns 422 if the key isn't registered or the signature doesn't verify. Binaries are refused until this succeeds.
  3. PUT .../platforms/{os}/{arch} (one per platform) — each zip is streamed to disk and its SHA checked against the signed manifest. Returns 422 on a SHA mismatch, or if the signature has not yet been verified.

Required permission: write on the provider (the owner has admin).

Tooling: the terrapod-publish CLI performs these three uploads (in order) and does all packaging, hashing, and GPG signing client-side. The server never re-signs — the download response advertises the publisher's own public key in signing_keys.gpg_public_keys.

Removed in the client-signed model: the previous presigned-URL flow — POST .../versions (create version) and POST .../versions/{version}/platforms (create platform, returning a presigned upload URL) — has been removed. There is no server-side re-signing and no presigned-URL or finalize step for provider versions. Use the three streamed PUT endpoints above.

Update Provider

PATCH /api/v1/registry-providers/private/default/{name}

Required permission: admin on the provider.

Self-lockout protection: If the request changes labels and the new labels would reduce the caller's own access level, the API returns 409 Conflict. Re-submit with "force": true in the attributes to confirm.

Provider Permissions Block

All provider responses (show and list) include a permissions object:

{
  "permissions": {
    "can-update": true,
    "can-destroy": true,
    "can-create-version": true
  }
}

GPG Keys

EndpointPermission
GET /api/v1/gpg-keysAny authenticated caller
GET /api/v1/gpg-keys/{id}Any authenticated caller
POST /api/v1/gpg-keysregistry:admin
POST /api/v1/gpg-keys/{id}/revokeregistry:admin
DELETE /api/v1/gpg-keys/{id}registry:admin

The read/write split is deliberate. The reads return public key material — the same bytes terraform init receives in every provider download response — so gating them would protect nothing while removing the ability to check which keys an instance trusts. A registration is the opposite: it adds a trust anchor, and every provider version signed by that key becomes installable by every runner on the instance.

The writes were open before v1.4.0 (authentication alone sufficed), so a service token that registers keys now needs a role granting registry admin — see registry-publishing.md. Grant it with registry_permission = "admin" and no allow_names or allow_labels: a role scoped to part of the registry cannot register a key, because a key is not scoped to part of the registry. Denied requests return 403 Requires registry:admin capability on the GPG key store.

The public key registered here is the trust anchor for client-signed provider publishing: PUT .../shasums.sig is verified against it. Register a key before publishing a provider (or use the terrapod_gpg_key provider resource). See Publishing to the Private Registry.

Revoke a key (.../gpg-keys/{id}/revoke) — POST an owner-issued revocation certificate (the armored output of gpg --gen-revoke) as the revocation-certificate attribute. Terrapod verifies it is a valid self key-revocation for the registered key, then stores the revoked key so all provider and runner signature verification fails closed for it — a revoked signing key can no longer verify anything, even already-published artifacts it signed. The key stays registered (auditable) rather than being deleted. Returns 422 if the certificate is not a genuine self-revocation for the key. (Signature verification honors revocation without shelling out to gpg; #640.)


Agent Pools

Pool Response Attributes

AttributeTypeNotes
namestringUnique pool name
descriptionstringFree text, "" when unset
labelsobjectLabel map used for pool RBAC
owner-emailstring / nullOwner gets admin on the pool
created-at / updated-atRFC3339
statusstringonline if at least one listener could take work, degraded if every live listener has an expired certificate, else offline
listener-countintegerListener identities that could take work — derived from the same predicate as status, so the two can never disagree. A listener that heartbeats with an expired certificate 401s every authenticated call and is not counted
listener-pod-countintegerPods backing those listeners. Replicas of one Deployment share a listener identity, so a redundant pair reports listener-count: 1 and listener-pod-count: 2. Omitted entirely when unknown — a listener on a pre-0.19.0 image does not report its pod name, and reporting zero would read as an outage. 0 is meaningful: the listener tracks pods and none are currently heartbeating
permissionstringThe calling user's resolved permission on this pool (read, write, admin)

status and both counts are present on the list and show endpoints; they are omitted from create/update responses, which do not fetch listeners.

List Pools

GET /api/v1/agent-pools

Create Pool

POST /api/v1/agent-pools

Request body:

{
  "data": {
    "type": "agent-pools",
    "attributes": {
      "name": "aws-prod",
      "description": "Production AWS runners"
    }
  }
}

Required permission: Platform admin.

Show Pool

GET /api/v1/agent-pools/{id}

Delete Pool

DELETE /api/v1/agent-pools/{id}

Pool Tokens

POST /api/v1/agent-pools/{id}/authentication-tokens
GET  /api/v1/agent-pools/{id}/authentication-tokens

Listener Join

POST /api/v1/agent-pools/join

Registers a listener using a join token. The token identifies the pool — no pool ID needed in the URL. No Bearer auth required; the join token in the body IS the credential.

Request body:

{
  "join_token": "<raw-token>",
  "name": "my-listener"
}

Response: listener ID, pool ID, X.509 certificate, private key, CA certificate.

If a listener with the same name already exists, its certificate is reissued (handles pod restarts).

Legacy endpoint (still supported):

POST /api/v1/agent-pools/{pool_id}/listeners/join

Listener Heartbeat

POST /api/v1/listeners/{id}/heartbeat

Listener Certificate Renewal

POST /api/v1/listeners/{id}/renew

Listener Run Polling

GET /api/v1/listeners/{id}/runs/next

Returns the next queued run for this listener.

vault-files (added in v1.7.0) carries the OpenBao/Vault secrets a variable asked to be delivered as files: a list of {name, path, secret_key, value}, which the listener writes into the run's existing per-run Secret and mounts read-only. The variable's own value is the file's path, so the secret itself never travels in terraform-vars or env-vars. A listener older than 1.7 ignores the attribute, and the run then fails with a variable naming a file that was never written — so upgrade listeners before using file delivery, as Vault → Older listeners sets out. Like every other attribute on this endpoint, it is part of the frozen runner/listener wire contract.

Listener Runner Token

POST /api/v1/listeners/{id}/runs/{run_id}/runner-token

Generates a short-lived HMAC-signed runner token scoped to the specified run. Called by the listener after claiming a run.

Request body (optional):

{
  "ttl": 3600
}
ParameterTypeDefaultDescription
ttlintegerrunners.tokenTTLSeconds (default 3600)Requested token lifetime in seconds. Clamped to runners.maxTokenTTLSeconds (default 7200)

Response:

{
  "token": "runtok:{run_id}:{ttl}:{timestamp}:{hmac_sig}",
  "expires_in": 3600
}

Auth: Listener certificate.

Listener Status Update

PATCH /api/v1/listeners/{id}/runs/{run_id}

Reports run status changes (planning, planned, applying, applied, errored).


VCS Connections

List Connections

GET /api/v1/vcs-connections

Create Connection

POST /api/v1/vcs-connections

GitHub example:

{
  "data": {
    "type": "vcs-connections",
    "attributes": {
      "name": "my-github",
      "provider": "github",
      "github-app-id": 12345,
      "github-installation-id": 67890,
      "github-account-login": "my-org",
      "github-account-type": "Organization",
      "private-key": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----",
      "webhook-secret": "optional-per-connection-secret"
    }
  }
}

webhook-secret (optional, both providers) is write-only — it is never returned; the response carries has-webhook-secret (boolean) instead. When set, this connection's inbound webhooks are validated against it: GitHub deliveries are HMAC-SHA256 verified, GitLab deliveries are matched against the X-Gitlab-Token header (timing-safe). GitHub falls back to the global vcs.github.webhook_secret when the per-connection secret is absent. On PATCH, supply a non-empty value to rotate, an explicit empty string to clear, or omit the key to leave it untouched.

GitLab example:

{
  "data": {
    "type": "vcs-connections",
    "attributes": {
      "name": "my-gitlab",
      "provider": "gitlab",
      "token": "glpat-xxxxxxxxxxxxxxxxxxxx"
    }
  }
}

Required permission: Platform admin.

Show Connection

GET /api/v1/vcs-connections/{id}

API budget and consumption attributes

Read-only, on every connection in both the list and show responses. They report the provider allowance and how fast it is being spent. Everything here is derived from rate-limit headers providers already return, so it costs no extra API calls — and is an observation as of the last call Terrapod made, not a live query.

AttributeTypeDescription
rate-limitinteger | nullTotal budget the provider reports. null when the server reports no rate limit at all
rate-limit-remaininginteger | nullRequests left in the current window. 0 (exhausted) and null (not reported) are different — do not conflate them
rate-limit-resourcestring | nullMetered resource (GitHub meters core, search and graphql separately)
rate-limit-reset-atRFC3339 | nullWhen the budget refills
rate-limit-observed-atRFC3339 | nullWhen the reading was taken
calls-per-hourinteger | nullCalls counted over the rate window
rate-window-minutesinteger | nullWidth of that window (60)
seconds-to-resetinteger | nullSeconds until the budget refills
saturationstring | nullidle | comfortable | tight | will_exhaust | exhausted
exhausts-in-secondsinteger | nullProjected seconds until the budget runs out at the current rate; null when it will not
top-consumersarray{name, kind, calls}, descending. kind is workspace, module, policy-set, or the subsystem when a call has no owning entity
label-totalsarray{label, key, value, calls}, descending — calls attributed to consumers carrying each label
budget-window-secondsinteger | nullHow long the provider's allowance refills over, inferred from the reset distance. Do not assume an hour — GitHub meters 5,000/hour, GitLab.com 2,000/minute. Scale calls-per-hour to this before reading it as a share of rate-limit. Null when no budget is reported.
consumers-window-totalintegerTotal calls across all consumers over the window top-consumers covers. Each entry's share divides by this; dividing by calls-per-hour mixes bases.

saturation is the attribute to act on, not rate-limit-remaining. The budget refills on a fixed window, so the remaining count reads healthy right after a reset however fast it is being spent; the verdict compares projected spend over the rest of the window against what is actually left. See VCS integration → Reading the consumption indicator.

Update Connection

PATCH /api/v1/vcs-connections/{id}

Partial update — only the attributes you include are changed. Notes:

  • provider is immutable. A different provider is a different connection; delete and recreate to change it (sending a different provider returns 422).
  • Credentials (private-key for GitHub, token for GitLab) are write-only: they are never returned, and are only rotated when you send a non-empty value. Omit them to change the name/server-url/status without touching the stored credential.
  • Editable: name, server-url, status (active/disabled), and the GitHub App identifiers (github-app-id, github-installation-id, github-account-login, github-account-type). Changing github-installation-id to one already used by another connection returns 422.
{
  "data": {
    "type": "vcs-connections",
    "attributes": { "name": "renamed", "server-url": "https://github.example.com/api/v3" }
  }
}

Required permission: Platform admin.

Delete Connection

DELETE /api/v1/vcs-connections/{id}

Autodiscovery Rules

Connection-scoped rules that auto-create workspaces when a PR or default-branch push touches a path matching pattern. See Autodiscovery for the full feature doc.

All endpoints require admin role.

List Rules

GET /api/v1/autodiscovery-rules

Create Rule

POST /api/v1/autodiscovery-rules

Request body (every attribute except name, vcs-connection-id, repo-url, pattern is optional):

{
  "data": {
    "type": "autodiscovery-rules",
    "attributes": {
      "name": "monorepo",
      "vcs-connection-id": "vcs-019e0e7b-...",
      "repo-url": "https://github.com/myorg/monorepo",
      "branch": "main",
      "pattern": "accounts/*/**/*.tf",
      "ignore-patterns": ["modules/**"],
      "name-template": "ws-{path}",
      "enabled": true,
      "execution-mode": "agent",
      "execution-backend": "tofu",
      "agent-pool-id": "apool-019e01db-...",
      "engine-version": "1.12",
      "resource-cpu": "1",
      "resource-memory": "2Gi",
      "auto-apply": false,
      "on-directory-delete": "flag",
      "labels": {"managed-by": "monorepo-autodiscover"},
      "owner-email": "platform@example.com"
    }
  }
}

Returns 201 with the created rule, or 409 if a rule with that name already exists for the connection.

Reserved label keys: labels is validated like any other label write — reserved keys (status, owner) are rejected with 422. This is enforced at rule create/update so the rule can't materialise workspaces that later become uneditable.

Show Rule

GET /api/v1/autodiscovery-rules/{id}

Update Rule

PATCH /api/v1/autodiscovery-rules/{id}

Same body shape as create; only the attributes you include are updated.

Delete Rule

DELETE /api/v1/autodiscovery-rules/{id}

Workspaces auto-created by this rule keep working — their autodiscovery-rule-id foreign key is set to NULL.

Preview (dry-run)

Walk the repo and return exactly which workspaces a rule would create against the current state of the tracked branch, with no side effects. Used by the admin UI's "Preview" modal.

GET  /api/v1/autodiscovery-rules/{id}/preview      # preview a saved rule
POST /api/v1/autodiscovery-rules/preview           # preview an unsaved rule (same attributes body as Create)

Each entry reports workspace_name, working_directory, collision (the row would no-op — a workspace is already bound to that directory or the derived name is taken), and existing_autodiscovered (the no-op is a reuse of a workspace this same rule already materialised). Returns 413 if the VCS provider truncated the repo tree (repo too large to scan in one pass).

Scan (on-demand materialise)

POST /api/v1/autodiscovery-rules/{id}/scan

Runs the same walk as Preview but actually creates the workspaces (idempotent, collision-safe). Force-enables the rule for the duration of the call so an explicit operator action doesn't silently no-op on a disabled rule. New workspaces are seeded with the tracked-branch HEAD as their last-seen commit, so the first real plan+apply fires when the branch next advances (e.g. the PR merge) — not immediately against a branch where the directory doesn't exist yet.

Rule templating (run tasks / notifications / var files)

POST/PATCH rule bodies accept three additional attributes that are materialised onto every workspace the rule creates, so autodiscovered workspaces are fully configured at creation:

  • var-files — list of var-file paths.
  • run-task-templates — list of run-task specs (same shape as the bulk-update run-tasks, below): {name, url, hmac-key?, stage, enforcement-level?, enabled?}.
  • notification-templates — list of notification specs: {name, destination-type, url?, token?, triggers?, email-addresses?, enabled?}.
  • execution-hook-templates — list of execution hook ids (hook-<uuid>) associated with every created workspace (#672).

These use the identical spec shape as the bulk-update endpoint, so a run task defined once can be applied to existing workspaces (bulk-update) and auto-applied to future ones (this template).


Module Autodiscovery Rules

Rules that find the modules (the root and any submodules) in one repository, or in every repository of an org, group or repository-name pattern, and register them in the private registry. See Module autodiscovery for how a rule behaves over time.

All endpoints require the platform admin role. Rule ids are modrule-<uuid>, and a raw UUID is accepted too. vcs-connection-id accepts vcs-<uuid> or a raw UUID.

List Rules

GET /api/v1/module-autodiscovery-rules

Newest first, with the standard meta.pagination block.

Create Rule

POST /api/v1/module-autodiscovery-rules

Request body (only name, vcs-connection-id, repo-url and pattern are required):

{
  "data": {
    "type": "module-autodiscovery-rules",
    "attributes": {
      "name": "management-groups",
      "vcs-connection-id": "vcs-019e0e7b-...",
      "repo-url": "https://github.com/myorg/terraform-azurerm-management-groups",
      "branch": "main",
      "pattern": "**/*.tf",
      "ignore-patterns": ["modules/legacy/**"],
      "name-template": "mg-{leaf}",
      "provider": "azurerm",
      "vcs-tag-pattern": "v*",
      "enabled": true,
      "labels": {"team": "platform"},
      "owner-email": "platform@example.com"
    }
  }
}
AttributeNotes
nameUnique per VCS connection.
vcs-connection-idThe connection whose credentials read the repository.
repo-urlWhat to scan: one repository (https://github.com/myorg/terraform-aws-vpc), an org or group (https://github.com/myorg), or a pattern over one org or group's repositories (https://github.com/myorg/terraform-*, glob characters in the last segment only). Classified when saved and reported as target-kind; see What a rule looks at.
branchEmpty means each repository's default branch.
pattern, ignore-patternsGitignore-style globs over .tf / .tf.json file paths. Each matching file's directory is a module. A pattern ending in / is refused, because it can only match a directory.
name-templateLiteral text plus the placeholders {repo}, {path}, {leaf}, {root} and {owner} (the repository's owner or group). Empty means the repository's module name plus the submodule's last segment.
providerLowercase letters, digits and hyphens. Empty means taken from a terraform-<provider>-<name> repository name.
vcs-tag-patternCopied onto each registered module. Empty means v*.
enabledA boolean; default true. While enabled, directories that appear on the tracked branch are registered automatically.
labels, owner-emailCopied onto each registered module. owner-email must be an email address, or empty.

Saving registers nothing: use Scan to register what's already in the repository.

Returns 201 with the created rule. 409 means a rule with that name already exists for the connection.

422 covers:

  • a missing required attribute;
  • a connection id that isn't a UUID, or that doesn't exist;
  • a repo-url that names nothing on the connection: a repository or group that doesn't exist, a host other than the connection's, another GitHub account than the one the App is installed on, three or more path segments on GitHub, or glob characters outside the last segment;
  • a pattern or ignore pattern ending in /;
  • ignore-patterns that isn't a list of strings;
  • a name-template with any other placeholder, a format spec, or any other brace;
  • an invalid provider;
  • enabled that isn't a boolean (the string "false" included);
  • an owner-email that isn't an email address;
  • a reserved label key.

502 means the VCS provider could not be asked while classifying repo-url; retry. A pattern that matches no repository yet is accepted.

Rule attributes in responses:

  • name
  • vcs-connection-id, as vcs-<uuid>
  • repo-url (as entered), branch, pattern, ignore-patterns, enabled
  • target-kind: read-only; repository, namespace (an org or group) or pattern. Fixed when the rule is saved; it changes only when repo-url or vcs-connection-id does.
  • name-template, provider, vcs-tag-pattern, labels
  • owner-email: "" when none
  • first-scan-at: null until the rule's first poll or scan (for an org-wide rule, its baseline)
  • last-scanned-sha: the tracked branch's head at the last scan; "" for an org-wide rule, whose heads are per repository
  • last-enumerated-at: when an org-wide rule last completed a listing of its repositories, or null
  • last-error: why the rule's last poll could not do all its work (its target was deleted, the listing failed or stopped at the repository cap, or the API quota ran low), or ""
  • created-at, updated-at

Each rule also has a vcs-connection relationship and a links.self.

Show Rule

GET /api/v1/module-autodiscovery-rules/{id}

Update Rule

PATCH /api/v1/module-autodiscovery-rules/{id}

Same body shape as create; only the attributes you include change, validated the same way. A changed repo-url or vcs-connection-id is classified again, as on create; so is re-saving the same repo-url of a rule with a last-error. Changing repo-url, vcs-connection-id, branch, pattern or ignore-patterns, or setting enabled to true on a disabled rule, starts the rule afresh: what it had seen no longer describes what it claims, so its per-repository state is cleared and the next poll records a new baseline rather than registering every directory the old rule never claimed. Register those with a scan.

Delete Rule

DELETE /api/v1/module-autodiscovery-rules/{id}

Returns 204. The modules the rule registered stay registered.

Preview (dry-run)

GET  /api/v1/module-autodiscovery-rules/{id}/preview   # a saved rule
POST /api/v1/module-autodiscovery-rules/preview        # an unsaved rule (same body as Create)

Registers nothing. A single-repository rule reads its repository live. An org-wide saved rule is served from what the last poll found, with no VCS calls, a page of repositories at a time (page[size], page[number], with meta.pagination); ?repository=<path-or-url> reads that one repository live instead (404 when it isn't one of the rule's repositories; 422 on a single-repository rule when it names another repository). An unsaved org-wide rule reads one page of repositories live (page[size] at most 25, default 10); a repository that cannot be read is reported in repositories, not as a failed preview.

Returns a module-autodiscovery-rule-previews document with:

  • ref: the branch it read; "" for a stored org-wide preview;
  • files-walked;
  • target-kind: as on the rule;
  • entries[]: one per candidate directory, root first, grouped by repository;
  • repositories[]: one per repository on this page, including ones with no candidates, each with repository (the path), repo-url, ref, status (active, archived, empty, no-branch, out-of-scope, covered or error), origin (baseline or new) and error;
  • listing-complete: false when an org-wide listing stopped at the repository cap, so some repositories are not shown.

Each entry has:

  • repository and repo-url: the repository the directory is in;
  • subdirectory: "" for the root;
  • name and provider: as a scan would register them;
  • registered-as: the {name, provider} of the module already registered from that directory, or null;
  • collision: true when the name and provider belong to another module, or when another unregistered candidate derives the same name;
  • missing-provider: true when no provider could be worked out.

Errors:

  • 422: a repository URL that can't be parsed, or an unknown VCS provider.
  • 502: the VCS provider can't be reached, returned no default branch, or refused to list the tree (a missing branch, a revoked token); the detail carries the provider's error.
  • 413: the provider truncated the repository's tree, so it's too large to scan in one pass.

Scan (register modules)

POST /api/v1/module-autodiscovery-rules/{id}/scan

With no body, registers every candidate. To register a chosen subset of a single-repository rule, send:

{"data": {"attributes": {"subdirectories": ["", "modules/create"]}}}

An org-wide rule registers from what the last poll found, with no VCS calls, and takes selections instead: the repositories to register from (a path or URL), each with optional subdirectories (omitted means all of that repository's candidates). At most 200 repositories per request. A single-repository rule accepts selections naming its one repository too.

{"data": {"attributes": {"selections": [
  {"repository": "myorg/terraform-aws-vpc"},
  {"repository": "myorg/terraform-aws-dns", "subdirectories": ["modules/zone"]}
]}}}

With no body, an org-wide rule registers every current candidate of every repository, which in a large org can be many modules.

Registered modules are VCS-sourced, carry their subdirectory, and take the rule's branch, tag pattern, labels and owner. Their tags are polled on the next registry poll. A scan works whether or not the rule is enabled. Everything it saw counts as seen, so automatic registration will never later pick up a candidate you left out.

Returns a module-autodiscovery-rule-scans document with:

  • ref and files-walked ("" and 0 for an org-wide rule);
  • modules-registered: the count;
  • modules[]: each with id, name, provider, subdirectory, repository and repo-url;
  • skipped[]: each with subdirectory and a reason (already-registered, name-taken or missing-provider), plus repository and repo-url for an org-wide rule;
  • repositories-scanned: how many repositories it registered from.

Returns 422 when a listed subdirectory isn't one of the rule's candidates, subdirectories or selections is malformed, a selection names a repository that isn't the rule's or has no candidates to register, or subdirectories is sent to an org-wide rule. Everything is checked before anything is registered. The repository errors are the same as for Preview.

Rule Repositories

GET /api/terrapod/v1/module-autodiscovery-rules/{id}/repositories

The repositories the rule looks at, by path, each with the state its polls keep: one for a single-repository rule once it has been polled, one per listed repository for an org-wide rule. Read-only, and separate from the rule so the rule stays small. filter[status]=<status> narrows by status. Paginated in the database (page[size] up to 100, page[number], meta.pagination); with no page[size] the whole list is returned.

Each module-autodiscovery-rule-repositories item (id modrepo-<uuid>, with a rule relationship) has:

  • repository: the path (owner/repo, or group/subgroup/project), and repo-url;
  • vcs-repo-id and default-branch;
  • origin: baseline (it existed at the rule's baseline, so nothing registers until it is scanned) or new (created afterwards, so its modules register automatically);
  • status: active, archived (kept, not scanned), empty, no-branch, out-of-scope (left the rule's target; its modules stay), covered (a single-repository rule on the connection names it) or error;
  • last-scanned-sha, seen-subdirectories;
  • candidates[]: {subdirectory, name, provider} as the last poll found them;
  • last-skips[]: {subdirectory, reason} from the last registration;
  • previous-paths[]: {path, url} before a rename or transfer, oldest first;
  • repo-created-at, first-seen-at, last-checked-at, next-check-at: RFC3339 or null;
  • failure-count and last-error: consecutive failed reads (they back off) and the last reason.

Bulk Workspace Operations

Terrapod-native, admin only. Server-side selection + atomic fleet updates (#318).

POST /api/v1/workspaces/actions/search

Resolve a structured filter to the matching workspaces — no side effects (the discovery half of the bulk workflow).

{ "filter": {
    "labels": {"team": "foundations"},
    "name-prefix": "terrapod-testing-",
    "execution-backend": "terraform",
    "agent-pool-id": "apool-...", "vcs-connection-id": "vcs-...",
    "owner-email": "...", "drift-status": "...", "locked": false, "has-vcs": true,
    "workspace-ids": ["ws-..."],
    "all": false } }

Dimensions are AND-combined (narrower = safer). An empty/omitted filter is a 422 (typo guard); matching the whole fleet requires explicit "all": true. Returns {matched, workspaces:[...]}.

Bulk Update

POST /api/v1/workspaces/actions/bulk-update

Apply update to every workspace matching filter, in a single all-or-nothing transaction.

{ "filter": { "labels": {"team": "foundations"} },
  "update": {
    "engine-version": "1.12",
    "execution-backend": "tofu",
    "auto-apply": false,
    "agent-pool-id": "apool-...",
    "resource-cpu": "1", "resource-memory": "2Gi",
    "var-files": ["envs/prod.tfvars"],
    "labels": {"reviewed": "2026-q2"},
    "run-tasks": [
      { "name": "opa-policy-check", "url": "http://opa:8080/webhook",
        "hmac-key": "secret", "stage": "post_plan", "enforcement-level": "mandatory" }
    ],
    "notification-configurations": [
      { "name": "slack-prod", "destination-type": "slack",
        "url": "https://hooks.slack.com/...", "triggers": ["run:errored"] }
    ]
  },
  "dry_run": true }

Semantics:

  • Validated once up front — field enums, labels reserved-key check, run-task/notification specs, and agent-pool existence + caller pool-write RBAC on every pool named. Any error ⇒ 422, zero mutation.
  • Agent pools accept either agent-pool-id (one pool, replacing the set) or agent-pool-ids (the set) — the same mutually-exclusive pair as the workspace endpoints; both in one update422.
  • run-tasks / notification-configurations upsert by (workspace, name): created if absent, updated in place if present (so re-running with a changed url rotates it across the fleet).
  • All-or-nothing: the whole batch commits or nothing does. dry_run (default true, not enforced) runs the identical code path and rolls back — the preview is exactly what apply would do, with provably zero side effects.
  • Triggers no runs — pure config write; the change lands on each workspace's next normal run. Reversible (it only writes settings rows).
  • Per-workspace audit entries.

Response: dry-run {dry_run:true, matched, would_change:[{id,name,diff}], unchanged}; apply {dry_run:false, matched, applied, changes, unchanged, errors:[]}; any failure ⇒ 409/422 and nothing applied.


VCS Events (Webhooks)

GitHub Webhook Receiver

POST /api/v1/vcs-events/github

Validates HMAC-SHA256 signature and triggers an immediate poll cycle. The webhook secret must match the connection's own webhook-secret, falling back to the global TERRAPOD_VCS__GITHUB__WEBHOOK_SECRET.

GitLab Webhook Receiver

POST /api/v1/vcs-events/gitlab

Validates the X-Gitlab-Token header (timing-safe comparison against the connection's webhook-secret) and triggers an immediate poll cycle. Handles Push Hook, Tag Push Hook, and Merge Request Hook events; other event types are acknowledged and ignored. A Tag Push Hook additionally triggers an immediate module tag poll, so a new module version publishes within seconds rather than waiting for vcs.module_poll_interval_seconds. Like GitHub, webhooks are an optional accelerator — the background poller still picks up changes within vcs.poll_interval_seconds if no webhook is configured.


Roles

List Roles

GET /api/v1/roles

Returns built-in and custom roles.

Required permission: Platform admin or audit.

Create Role

POST /api/v1/roles

Request body (capability authoring — recommended):

{
  "data": {
    "type": "roles",
    "attributes": {
      "name": "developer",
      "description": "Development workspace access",
      "capabilities": ["workspace:read", "run:read", "run:plan", "var:read"],
      "allow-labels": {"env": "dev"},
      "allow-names": [],
      "deny-labels": {},
      "deny-names": []
    }
  }
}

A role's grant is its capabilities set — a list of resource:verb tokens (e.g. run:plan, run:apply, run:apply-destroy, workspace:delete, var:write, state:read) and the single stored source of truth for enforcement (#585). Capabilities express grants the old hierarchical levels could not — for example run:plan without run:apply ("plan but not apply"). Only tokens in the grantable set are accepted; platform:* and unknown tokens are rejected with 422.

Request body (level shorthand — convenience): you may instead send the four permission-level fields and the server expands them into capabilities on write:

{
  "data": {
    "type": "roles",
    "attributes": {
      "name": "developer",
      "workspace-permission": "write",
      "pool-permission": "read",
      "registry-permission": "read",
      "catalog-permission": "use",
      "allow-labels": {"env": "dev"}
    }
  }
}
  • workspace-permission (read/plan/write/admin), pool-permission (read/write/admin), registry-permission (read/write/admin, modules + providers) default to read; catalog-permission (none/read/use/admin) defaults to none (opt-in, no everyone floor).
  • The levels are not persisted. On a create/update they are expanded into capabilities; on a PATCH, a level field replaces only that axis's capabilities, preserving granular capabilities on the other axes.
  • If both capabilities and level fields are sent, capabilities wins (the levels are ignored).

Response: every roles response includes the stored capabilities list and a derived, read-only permission-level summary (workspace-permission, pool-permission, registry-permission, catalog-permission) computed from the capabilities — each is the matching preset name, or the literal "custom" when the capability set matches no preset. Consumers that write levels must tolerate reading back "custom".

Required permission: Platform admin.

Show Role

GET /api/v1/roles/{name}

Update Role

PATCH /api/v1/roles/{name}

Delete Role

DELETE /api/v1/roles/{name}

Built-in roles cannot be deleted.

Role attribute: allow-all

allow-all: true makes the role's allow side match every resource on every axis, including ones created later. Deny rules still take precedence, and it does not raise the role's permission level. It exists because label and name rules are exact-match (no wildcards), so the alternative was a shared label on every workspace — where a workspace created without it silently falls outside the role. Defaults to false; every pre-existing role keeps exactly the reach it had.

Resource Access (who can reach this)

GET /api/v1/workspaces/{id}/access
GET /api/v1/agent-pools/{id}/access
GET /api/v1/registry-modules/{id}/access
GET /api/v1/registry-providers/{id}/access
GET /api/v1/catalog-items/{id}/access

The inverse of the role preview. admin or audit; read-only and unpaged (roles are few).

{
  "data": {
    "type": "resource-access",
    "id": "ws-...",
    "attributes": {
      "resource": {"id": "ws-...", "kind": "workspaces", "name": "prod-api",
                   "labels": {"env": "prod"}, "owner-email": "team@example.com"},
      "axis": "workspace",
      "role-count": 1,
      "roles": [
        {"role": "sre", "verdict": "allowed", "reason": "allow-label:env=prod",
         "capabilities": ["run:apply", "..."], "notes": ["has-owner"],
         "held-by": ["alice@example.com"]}
      ],
      "denied-roles": [
        {"role": "contractors", "verdict": "denied", "reason": "deny-name",
         "capabilities": []}
      ],
      "platform-paths": ["platform-admin", "platform-audit", "owner"]
    }
  }
}

platform-paths names access that exists independently of any roleplatform-admin, platform-audit, owner, everyone-floor, catalog-clamped. A caller that reports only roles will understate who can reach the resource.

Preview Role Reach

POST /api/v1/roles/preview
GET  /api/v1/roles/{name}/preview

Which workspaces a role grants on, and why. admin or audit; read-only.

POST previews an unsaved role body — the same attributes a create takes (allow-labels, allow-names, deny-labels, deny-names, and either capabilities or the level shorthand) — so the admin UI can show the match while a rule is being typed. Nothing is persisted, and a body a create would reject is rejected here too (422).

GET previews a saved role. A built-in role returns 422: admin and audit grant through the platform path on every workspace, so a label-reach figure for them would be misleading rather than useful.

Paged with page[size] (default 25, max 100) and page[number]. The counts span the whole fleet, not the returned page.

The response carries an axes block keyed workspace / pool / registry / catalog: a role's rules are matched the same way whatever they are matched against, so one rule reaches agent pools, registry modules and providers, and catalog items as readily as workspaces. Capabilities in each block are sliced to that axis. The workspace axis is also promoted to the top level as workspaces / denied, since it is what most callers want.

{
  "data": {
    "type": "role-previews",
    "id": "sre",
    "attributes": {
      "granted-count": 47,
      "denied-count": 3,
      "matched-count": 50,
      "denied-truncated": false,
      "workspaces": [
        {
          "id": "ws-...",
          "name": "prod-api",
          "labels": {"env": "prod"},
          "owner-email": "team@example.com",
          "verdict": "allowed",
          "reason": "allow-label:env=prod",
          "capabilities": ["run:apply", "run:plan", "..."],
          "notes": ["has-owner"]
        }
      ],
      "denied": [
        {
          "id": "ws-...",
          "name": "prod-locked",
          "verdict": "denied",
          "reason": "deny-label:sealed=yes",
          "capabilities": []
        }
      ]
    }
  }
}

verdict is allowed or denied; reason names the responsible rule. notes name access paths independent of this role — has-owner, everyone-floor, catalog-clamped — see rbac.md → Seeing What a Role Reaches.


Role Assignments

List Assignments

GET /api/v1/role-assignments

Required permission: Platform admin or audit.

Set Roles for User

PUT /api/v1/role-assignments

Request body:

{
  "data": {
    "type": "role-assignments",
    "attributes": {
      "provider-name": "local",
      "email": "alice@example.com",
      "roles": ["developer", "sre-reader"]
    }
  }
}

Required permission: Platform admin.

Remove Single Assignment

DELETE /api/v1/role-assignments/{provider}/{email}/{role}

Authentication Tokens

Authentication tokens come in three kinds (kind attribute):

KindWho createsEffective permissionsBound to
interactiveanyone (default)the owner's full live rolesthe owner
service_boundanyoneintersection of the token's pinned-roles and the owner's live roles, per resourcethe owner — and the token is rejected if the owner hasn't logged in within auth.bound_token_idle_days (default 7)
service_detachedadmins onlythe token's pinned-roles as an absolute scopenobody (unbound) — survives any single person leaving

A service_bound token can never exceed its owner's access (the intersection caps it) and stops working when the owner is offboarded — see authentication.md and the offboarding runbook in runbooks.md. service_detached is the path for critical machine-to-machine automation.

Response attributes: description, kind, bound-to (null for detached), created-by, pinned-roles (service tokens only; null for interactive), token-type, created-at, rotated-at, last-used-at, expires-at, lifespan-hours, and token (the raw secret — present only in create/rotate responses). Service tokens always carry an expiry, capped by auth.service_token_max_ttl_hours (default 8760 / 1 year).

Create Token

POST /api/v1/users/{user_id}/authentication-tokens

Request attributes: description, kind (default interactive), lifespan_hours, pinned_roles (service kinds). service_detached is admin-only (403 otherwise) and is created unbound regardless of {user_id}.

List Own Tokens

GET /api/v1/users/{user_id}/authentication-tokens

Never includes detached tokens (they are unbound).

List All Tokens (admin)

GET /api/v1/admin/authentication-tokens[?kind={kind}]

Admin-only. Optional kind filter (interactive / service_bound / service_detached); a valid-but-empty kind returns [], not an error.

Show Token

GET /api/v1/authentication-tokens/{id}

Re-tag Token (change kind)

PATCH /api/v1/authentication-tokens/{id}

interactiveservice_bound is owner-or-admin. Converting to/from service_detached is admin-only and unbinds/rebinds the token. Request attributes: kind, pinned_roles.

Rotate Token

POST /api/v1/authentication-tokens/{id}/actions/rotate

Mints a fresh secret (returned once in token) and resets the expiry clock; the old secret stops working immediately. Surfaced as a "Rotate" action on service tokens in the UI.

List Expiring Service Tokens

GET /api/v1/authentication-tokens/expiring

Service tokens within auth.token_expiry_warning_days (default 14) of expiry, scoped to the caller: own bound service tokens for everyone, plus all detached tokens for admins. Drives the in-app expiry banner — no user is warned about another user's bound tokens.

Revoke All Tokens for a User (admin)

POST /api/v1/admin/authentication-tokens/actions/revoke-all

Admin-only urgent-offboarding lever. Body {"email": "..."}; revokes every token bound to that identity and returns {"data": {"email": ..., "revoked": N}}. Detached tokens are unbound and unaffected.

Delete Token

DELETE /api/v1/authentication-tokens/{id}

Run Artifacts (Runner)

Authenticated endpoints for runner Jobs to download inputs and upload outputs. All endpoints require a runner token (Authorization: Bearer runtok:...) scoped to the specified run_id.

Download Config Archive

GET /api/v1/runs/{run_id}/artifacts/config

Returns 302 redirect to presigned storage URL for the configuration tarball.

Download State

GET /api/v1/runs/{run_id}/artifacts/state

Returns 302 redirect to presigned storage URL for the current workspace state.

Download Plan File

GET /api/v1/runs/{run_id}/artifacts/plan-file

Returns 302 redirect to presigned storage URL for the plan binary file.

Upload Plan Log

PUT /api/v1/runs/{run_id}/artifacts/plan-log
Content-Type: application/octet-stream

Upload raw plan log bytes. Returns 204 on success.

Upload Plan File

PUT /api/v1/runs/{run_id}/artifacts/plan-file
Content-Type: application/octet-stream

Upload plan binary file. Returns 204 on success.

Upload Apply Log

PUT /api/v1/runs/{run_id}/artifacts/apply-log
Content-Type: application/octet-stream

Upload raw apply log bytes. Returns 204 on success.

Upload State

PUT /api/v1/runs/{run_id}/artifacts/state
Content-Type: application/octet-stream

Upload new state after apply. Returns 204 on success.

Download a Pulumi Deployment

GET /api/v1/runs/{run_id}/artifacts/pulumi-deployment

Pulumi workspaces only (#1576). Returns 200 with {"version": 3, "deployment": ...}, the body pulumi stack import reads: the stack's current deployment with its secrets in plaintext and no secrets_providers block. deployment is null for a stack with no state. The X-Terrapod-State-Serial header carries the serial of the state version it was read from, or 0. Returns 409 when the stored secrets are sealed by a provider Terrapod holds no key for, and 404 for a workspace that is not Pulumi.

Upload a Pulumi Deployment

PUT /api/v1/runs/{run_id}/artifacts/pulumi-deployment?base-serial={serial}
Content-Type: application/json

Pulumi workspaces only (#1576). The body is pulumi stack export --show-secrets output. Terrapod seals the secrets with its own key and stores the result as the next state version, linked to the run. base-serial is the serial the download reported; if the stack has moved on since, the upload is refused with 409. A retry of an upload that already landed returns 200. Also returns 400 for a body with sealed secrets or no deployment, and 409 for a plan-only run. Returns 204 on success.

Download Plan Artifacts

GET /api/v1/runs/{run_id}/artifacts/plan-artifacts

Returns 302 redirect to presigned storage URL for the plan-phase workspace-diff tarball. Used by the apply phase to restore files generated during plan (e.g. data.archive_file outputs, null_resource local-exec scratch) into the apply Job's fresh workspace. Returns 404 if the run was produced by a pre-v0.34.0 runner (no plan-artifacts uploaded). The apply phase tolerates 404 — it logs an error and proceeds.

Upload Plan Artifacts

PUT /api/v1/runs/{run_id}/artifacts/plan-artifacts
Content-Type: application/x-tar
Content-Length: <bytes>

Upload the plan-phase workspace-diff tarball. The body is the diff (post_plan - post_init) of files plan created, excluding tfplan, .terraform.lock.hcl, and .terraform/terraform.tfstate (handled by other endpoints). An empty tar is always uploaded — even when plan generated no new files — so the apply-side contract is "404 means upload genuinely failed", not "no new files".

The body is streamed to an ephemeral tempfile on the API pod's PVC and forwarded to object storage; nothing is fully buffered in RAM. Maximum tarball size is runners.planArtifactsMaxBytes (default 256 MiB; minimum 10240 bytes). Both the Content-Length header and the streamed length are enforced — oversize uploads receive HTTP 413.

Returns 204 on success.

Record Resource Profile

POST /api/v1/runs/{run_id}/resource-profile
Content-Type: application/json

Called by the runner entrypoint at exit (EXIT trap, fires on every catchable termination — clean success, plan errored, OPA failed, SIGTERM during apply). Captures cgroup-v2 peak memory + cumulative CPU + the runner script's exit code.

Body (all fields optional — the runner sends whatever it could read):

{
  "peak_memory_bytes": 1500000000,
  "peak_cpu_usec": 42000000,
  "exit_code": 1,
  "failure_reason": "Error: Unsupported argument (on main.tf line 3)"
}
  • peak_memory_bytes — from /sys/fs/cgroup/memory.peak
  • peak_cpu_usecusage_usec from /sys/fs/cgroup/cpu.stat
  • exit_code — script's actual exit status
  • failure_reason — sent only with a non-zero exit_code: why the run failed, in a line or two. For a failed init, plan or apply it is tofu's own Error: summaries (at most three, each with its on <file> line <n> location, never a diagnostic's detail lines); for any other failure it is the runner's last logged error (a failed hook, an unusable configuration archive, a crash). The API strips ANSI and other control characters, caps it at 2,000 characters, and stores it as the run's error-message, which the reconciler then keeps, followed by Runner exited with code N (#1631). Ignored with a zero exit_code, and on a run that has already errored.

Negative values, non-integers, or booleans return 400, as does a non-string failure_reason. Missing fields are not clobbered (existing values preserved).

Note: SIGKILL is uncatchable, so this endpoint never fires on OOM-killed runs. Those are covered by the listener's K8s-terminated-state report on the job-status path; runner-exit-status ends up "oom" either way. See Run Response Attributes (Resource Profile / OOM) for the full signal flow.

Returns 204 on success.


Binary Cache

Download Binary

GET /api/v1/binary-cache/{tool}/{version}/{os}/{arch}

Returns a 302 redirect to a presigned URL for the binary. tool is terraform or tofu.

Required: Authentication (runner token, API token, or session).

List Cached Binaries (Admin)

GET /api/v1/admin/binary-cache

Warm Cache (Admin)

POST /api/v1/admin/binary-cache/warm

Pre-cache a specific tool version.

Bulk Warm Cache (Admin)

POST /api/v1/admin/binary-cache/warm-bulk

Pre-cache many binaries and/or provider platforms in one call. Request body:

{
  "binaries": [
    { "tool": "tofu", "version": "1.9.0", "platforms": [{ "os": "linux", "arch": "amd64" }] }
  ],
  "providers": [
    { "source": "registry.terraform.io/hashicorp/aws", "version": "5.60.0" }
  ]
}

platforms is optional — omitted, a binary falls back to linux/amd64 + linux/arm64 and a provider falls back to the configured provider_cache.platforms. Provider source is the hostname/namespace/type address. Resilient: each (entry, platform) is warmed independently and reported back, so one missing version doesn't fail the batch. Returns HTTP 200 with per-target results even when some failed:

{ "total": 2, "succeeded": 1, "failed": 1, "results": [
  { "kind": "binary", "ref": "tofu 1.9.0 linux/amd64", "ok": true },
  { "kind": "provider", "ref": "registry.terraform.io/hashicorp/aws 5.60.0 linux/amd64", "ok": false, "error": "..." }
]}

See Cache pre-population.

Purge Cache (Admin)

DELETE /api/v1/admin/binary-cache/{tool}/{version}

Encryption at Rest (Admin)

Optional application-layer BYOK envelope encryption — off by default. See Encryption at Rest for the full operator guide. Both endpoints require platform admin.

Status

GET /api/v1/admin/encryption

Reports encryption-at-rest health. decryptable is the headline durability signal — false means the platform is running but cannot read its encrypted data back (page on it). Always returns 200 (even when encryption is disabled).

Response attributes (data.type = encryption-status):

AttributeTypeMeaning
enabledboolWhether new writes are encrypted
providerstringKEK provider in use (static / vault_transit / awskms / empty when off)
active_versionint | nullDEK version new writes use
dek_versionsarrayAll DEK versions loaded (older ones decrypt existing data)
canary_okboolThe boot decryptability canary verified
decryptableboolCan currently decrypt — the alarm signal

Rotate DEK

POST /api/v1/admin/encryption/rotate-dek

Mints a new active data-encryption key. Prior DEK versions are retained so existing ciphertext stays decryptable; run encryption_migrate encrypt afterwards to re-key old rows. The new key is wrapped and unwrapped (round-trip verified) before activation — a broken provider aborts with nothing changed. Returns the same status shape as above. 409 when encryption is disabled.

Rotation propagates to all API replicas within ~30s via the encryption_key_refresh background task (no restart needed); see the rotation notes.


Agent Pool Events (SSE)

GET /api/v1/agent-pools/{pool_id}/events

Server-Sent Events stream for real-time agent pool updates. Emits events when listeners heartbeat or join the pool. Used by the agent pool detail page for live listener status updates.

Event types:

EventTrigger
listener_heartbeatA listener sends its periodic heartbeat
listener_joinedA new listener joins the pool

Required permission: Platform admin or audit.


Provider Cache (Network Mirror)

All provider mirror endpoints require authentication (runner token, API token, or session).

Provider Version Index

GET /api/v1/provider-mirror/{hostname}/{namespace}/{type}/index.json

Provider Version Details

GET /api/v1/provider-mirror/{hostname}/{namespace}/{type}/{version}.json

Returns platform-specific download URLs with zh: (zip hash) checksums.


Auth Endpoints

List Auth Providers

GET /api/v1/auth/providers

Authorize (Start Login)

GET /api/v1/auth/authorize

Callback (IDP Return)

GET /api/v1/auth/callback
POST /api/v1/auth/callback

Active Sessions

GET /api/v1/auth/sessions

Session Status

GET /api/v1/auth/session

Returns the caller's live web-session status for the expiry-warning banner (#726): { "authenticated": true, "ttl_seconds": <int> }. ttl_seconds is the session's true remaining lifetime read straight from Redis without sliding it, so the web client reconciles against the server's real TTL instead of a stale client-cached timestamp — SSE-driven views slide the server session without emitting the X-Session-Expires header, which used to make the banner warn falsely. A 401 is the authoritative "session is gone → re-authenticate" signal (session auth only; API-token callers get 401 here).

Logout

POST /api/v1/auth/logout

OAuth (Terraform Login)

Authorize

GET /oauth/authorize

Token Exchange

POST /oauth/token

Audit Log

Immutable record of API requests. Requires admin or audit role.

List Audit Log Entries

GET /api/v1/admin/audit-log

Query Parameters:

ParameterTypeDescription
filter[actor]stringFilter by actor email
filter[resource-type]stringFilter by resource type (e.g. workspaces, runs)
filter[action]stringFilter by HTTP method (GET, POST, PATCH, DELETE)
filter[since]datetimeOnly entries after this timestamp (RFC3339)
filter[until]datetimeOnly entries before this timestamp (RFC3339)
page[number]integerPage number (default: 1)
page[size]integerPage size (default: 20, max: 100)

Response: JSON:API list of audit-log-entries with pagination metadata.

Besides HTTP requests, the log holds system events, whose action is a verb. Every OpenBao/Vault read Terrapod makes for a run is one vault.read row (resource-type runs), whose detail is JSON naming the variables, instance, mount, path, engine, phase and outcome (ok, denied, missing, transient, error), never a value. Filter with filter[action]=vault.read. See OpenBao/Vault → The audit trail.

Example:

curl "https://terrapod.example.com/api/v1/admin/audit-log?filter[actor]=admin@example.com&page[size]=10" \
  -H "Authorization: Bearer $TERRAPOD_TOKEN"

Users

User management endpoints. List and show require admin or audit role. Update and delete require admin role.

List Users

GET /api/v1/users

Query Parameters:

ParameterTypeDescription
filter[email]stringFilter by email (case-insensitive substring match)
page[number]integerPage number (default: 1)
page[size]integerPage size (default: 20, max: 100)

Show User

GET /api/v1/users/{email}

Update User

PATCH /api/v1/users/{email}

Updatable attributes: is-active, display-name.

When is-active is set to false, all sessions for that user are revoked immediately.

Delete User

DELETE /api/v1/users/{email}

Cascades: revokes all sessions, deletes all role assignments.


Notification Configurations

Workspace-scoped notifications that fire on run lifecycle events. Three destination types: generic (webhook with HMAC-SHA512 signing), slack (Block Kit formatted), and email (SMTP).

Create Notification Configuration

POST /api/v1/workspaces/{id}/notification-configurations

Request body:

{
  "data": {
    "type": "notification-configurations",
    "attributes": {
      "name": "deploy-alerts",
      "destination-type": "generic",
      "url": "https://example.com/webhook",
      "token": "my-hmac-secret",
      "enabled": true,
      "triggers": ["run:completed", "run:errored"]
    }
  }
}

Destination types:

TypeRequired fieldsOptional
genericurltoken (HMAC-SHA512 signing)
slackurl (Slack webhook URL)
emailemail-addresses (list)

Valid triggers: run:created, run:planning, run:needs_attention, run:planned, run:applying, run:completed, run:errored, run:drift_detected

Required permission: admin on the workspace.

List Notification Configurations

GET /api/v1/workspaces/{id}/notification-configurations

Required permission: read on the workspace.

Show Notification Configuration

GET /api/v1/notification-configurations/{id}

Required permission: read on the associated workspace.

Update Notification Configuration

PATCH /api/v1/notification-configurations/{id}

Same body format as create. Only include attributes to change. Token is never returned in responses — only has-token: true/false.

Required permission: admin on the workspace.

Delete Notification Configuration

DELETE /api/v1/notification-configurations/{id}

Required permission: admin on the workspace.

Verify Notification Configuration

POST /api/v1/notification-configurations/{id}/actions/verify

Sends a test payload to the configured destination and returns the delivery response.

Required permission: admin on the workspace.


Slack account linking

Binds a Slack identity (team + user) to a Terrapod identity, established once via an explicit login (the "connect your Terrapod account" flow) and reused for every subsequent Slack-initiated action. The binding is long-lived identity, never entitlement — RBAC is re-checked live on each action. See Slack integration (#556).

POST /api/v1/slack/link/preview

Body {"state": "<signed-state>"}. Requires an authenticated Terrapod user. Describes which Slack identity the signed state would bind — returns {data: {slack-team-id, slack-user-id, email}} (the caller's email) — without consuming the single-use state, so the browser can show an explicit confirm screen before binding (the confused-deputy defence). 422 if the state is missing, 400 if it is invalid/expired/already used. Binding still happens only on POST /slack/link.

POST /api/v1/slack/link

Body {"state": "<signed-state>"}. Requires an authenticated Terrapod user; verifies + consumes the single-use signed state (minted by the /terrapod link flow) and binds the acting user's identity to the Slack (team, user) in the state. 422 if the state is missing, 400 if it is invalid/expired/already used.

GET /api/v1/slack/links

Returns the current user's Slack identity links.

DELETE /api/v1/slack/links/{link_id}

Removes one of the current user's own links (404 if it isn't theirs).

Execution Hooks

Reusable custom-shell steps run inside the runner Job at fixed points (pre_init, pre_plan, post_plan, pre_apply, post_apply). A hook is an admin-managed library entry, associated with the workspaces that use it — there is no global scope. See Execution Hooks for the full guide. Delivery is gated by the platform kill-switch runners.hooksEnabled (Helm, default true).

Create Execution Hook

POST /api/v1/execution-hooks

Request body:

{
  "data": {
    "type": "execution-hooks",
    "attributes": {
      "name": "internal-hosts-entry",
      "description": "Add an internal registry host before init",
      "hook-point": "pre_init",
      "script": "echo '10.0.0.5 registry.internal' >> /etc/hosts",
      "enabled": true,
      "priority": 0
    }
  }
}

Valid hook-point: pre_init, pre_plan, post_plan, pre_apply, post_apply. An unknown point returns 422; a duplicate name returns 409.

Required permission: admin.

List Execution Hooks

GET /api/v1/execution-hooks

Required permission: admin.

Show Execution Hook

GET /api/v1/execution-hooks/{id}

Required permission: admin.

Update Execution Hook

PATCH /api/v1/execution-hooks/{id}

Same body format as create; include only the attributes to change.

Required permission: admin.

Delete Execution Hook

DELETE /api/v1/execution-hooks/{id}

Removes the hook and all its workspace associations.

Required permission: admin.

Associate / Dissociate Workspaces

POST   /api/v1/execution-hooks/{id}/relationships/workspaces
DELETE /api/v1/execution-hooks/{id}/relationships/workspaces

Request body (both verbs, idempotent):

{ "data": [{ "id": "ws-...", "type": "workspaces" }] }

Required permission: admin.


Policy Sets

OPA policy-as-code enforcement. Policy sets and their policies are admin-managed; per-run policy evaluations are readable by anyone with read on the run's workspace. See policies.md for the Rego authoring contract.

List Policy Sets

GET /api/v1/policy-sets

Returns all policy sets. Required permission: admin or audit.

Create Policy Set

POST /api/v1/policy-sets
{
  "data": {
    "type": "policy-sets",
    "attributes": {
      "name": "production-guardrails",
      "description": "Mandatory guardrails for production",
      "enforcement-level": "mandatory",
      "global-scope": false,
      "allow-labels": {"env": ["prod"]},
      "deny-names": ["prod-sandbox"]
    }
  }
}

enforcement-level is advisory (default) or mandatory. Scoping is global-scope: true (every workspace) or the allow-labels / allow-names / deny-labels / deny-names rules (same label model as roles; deny wins). Required permission: admin.

VCS-Sourced Policy Sets

Set source to "vcs" to create a policy set that syncs .rego files from a git repository instead of managing policies inline via the API:

{
  "data": {
    "type": "policy-sets",
    "attributes": {
      "name": "security-baseline",
      "enforcement-level": "mandatory",
      "source": "vcs",
      "vcs-connection-id": "vcs-<uuid>",
      "vcs-repo-url": "https://github.com/org/policies",
      "vcs-branch": "main",
      "policy-path": "policies"
    }
  }
}

VCS-specific attributes:

AttributeTypeDescription
sourcestring"inline" (default) or "vcs"
vcs-connection-idstringRequired when source=vcs. References a VCS connection (vcs-<uuid> format).
vcs-repo-urlstringRequired when source=vcs. HTTPS clone URL of the repo.
vcs-branchstringBranch to track. Defaults to the repo's default branch if empty.
policy-pathstringDirectory within the repo containing .rego files. Only direct children are loaded (no recursive descent). Empty string means repo root.
vcs-last-commit-shastringRead-only. SHA of the last successfully synced commit.
vcs-last-synced-atstringRead-only. RFC 3339 timestamp of last successful sync.
vcs-last-errorstring|nullRead-only. Error message from the most recent sync attempt, or null.

When source=vcs, inline policy CRUD is rejected with 409 Conflict — policies are managed exclusively by the linked repository.

Sync VCS Policy Set

POST /api/v1/policy-sets/{id}/actions/sync

Triggers an immediate sync of a VCS-sourced policy set. Returns 202 Accepted with the current policy set state; the actual sync runs asynchronously. Returns 409 Conflict if the policy set has source=inline. Required permission: admin.

Show / Update / Delete Policy Set

GET    /api/v1/policy-sets/{id}     # policies embedded
PATCH  /api/v1/policy-sets/{id}     # partial update
DELETE /api/v1/policy-sets/{id}

Deleting a set removes its policies; recorded run evaluations are kept (set reference nulled, name snapshot retained). Required permission: admin (admin/audit for show).

Manage Policies

POST   /api/v1/policy-sets/{id}/policies   # add a policy
PATCH  /api/v1/policies/{id}               # update
DELETE /api/v1/policies/{id}
{
  "data": {
    "type": "policies",
    "attributes": {
      "name": "no-public-buckets",
      "rego": "package terrapod\n\ndeny contains msg if { ... }"
    }
  }
}

The Rego is validated with opa check on create/update — broken Rego, or Rego that does not declare package terrapod, is rejected with 422. Required permission: admin.

List Run Policy Evaluations

GET /api/v1/runs/{run_id}/policy-evaluations

Returns the policy evaluations recorded for a run, plus a meta.summary (status: passed / advisory-failed / blocked, and counts). Each evaluation's result carries the per-policy violations/warnings. This is the endpoint behind the run's policy-checks relationship link. For a run whose engine does not evaluate policy sets (Pulumi, until #1560), meta.not-evaluated-reason says why there are no evaluations. Required permission: read on the run's workspace.

Override Run Policy

POST /api/v1/runs/{run_id}/actions/override-policy

Overrides every failed/errored policy evaluation of a run and immediately re-drives a run held at the post-plan policy gate. Required permission: admin on the run's workspace.

Policy checks

GET  /api/tfe/v2/runs/{run_id}/policy-checks
GET  /api/tfe/v2/policy-checks/{id}
GET  /api/tfe/v2/policy-checks/{id}/output
POST /api/tfe/v2/policy-checks/{id}/actions/override

The run's post-plan gates in the Terraform Enterprise policy-checks shape that the tofu/terraform CLI reads (#1704). A run has up to two checks, each present only when its gate recorded something: polchk-opa-<run uuid> for its OPA policy sets (scope: organization) and polchk-scan-<run uuid> for its security scan (scope: workspace).

{
  "data": {
    "id": "polchk-opa-01a0af8c-79a0-759c-86e3-3607f5b419e6",
    "type": "policy-checks",
    "attributes": {
      "status": "soft_failed",
      "scope": "organization",
      "result": {"result": false, "passed": 1, "total-failed": 1, "hard-failed": 0,
                 "soft-failed": 1, "advisory-failed": 0, "duration": 0},
      "actions": {"is-overridable": true},
      "permissions": {"can-override": true},
      "status-timestamps": {"queued-at": "2026-09-17T12:30:00Z", "soft-failed-at": "2026-09-17T12:30:00Z"}
    },
    "relationships": {"run": {"data": {"id": "run-01a0af8c-79a0-759c-86e3-3607f5b419e6", "type": "runs"}}}
  }
}

status is soft_failed while a mandatory policy set's (or an enforced scan's) failure is not overridden, overridden once it is, and passed otherwise — an advisory failure passes, counted in advisory-failed. /output returns text/plain: each policy set with its denies and warnings, or the scan's findings, worst first. The override overrides everything the check covers (every failed policy set, or the scan) and moves a held run on at once; it answers 409 for a check that is not soft_failed. The run advertises these checks in its policy-checks relationship only in the Terraform Enterprise vocabulary, but the endpoints are always served.

Required permission: read on the run's workspace to list or read (a caller without it gets 404); admin to override, which is also what permissions.can-override reports.

Runner protocol — Policy Bundle

GET /api/v1/runs/{run_id}/policy-bundle

Returns the applicable policy sets + run/workspace context for a run. Used by the runner during the plan phase to drive OPA evaluation locally. A persistent fetch failure on the runner is fatal to the run (see docs/runners.md — OPA Policy Evaluation) — there is no silent skip. Response shape is a flat JSON document (not JSON:API — the runner is the only consumer):

{
  "policy_sets": [
    {
      "id": "polset-...",
      "name": "...",
      "enforcement_level": "mandatory",
      "policies": [
        {"id": "pol-...", "name": "...", "rego": "package terrapod\n..."}
      ]
    }
  ],
  "context": {
    "workspace": {"id": "...", "name": "...", "labels": {...}},
    "run": {"id": "...", "message": "...", "source": "...", "is_destroy": false, "plan_only": false}
  }
}

policy_sets is an empty list when nothing is in scope for the workspace — the runner then skips evaluation entirely. Required permission: runner token scoped to this run_id.

Runner protocol — Policy Results

POST /api/v1/runs/{run_id}/policy-results
{
  "results": [
    {
      "policy_set_id": "polset-...",
      "policy_set_name": "...",
      "enforcement_level": "mandatory",
      "outcome": "failed",
      "result": {
        "policies": [
          {"policy": "...", "passed": false, "violations": ["..."], "warnings": [], "error": null}
        ],
        "evaluated_at": "2026-05-24T10:00:00Z"
      }
    }
  ]
}

Records the runner's policy-evaluation outcomes for the run. Persisted via Postgres ON CONFLICT DO NOTHING on (run_id, policy_set_id) so a retried POST after a transient failure is idempotent. The runner POSTs this before posting plan-result, so the API's post-plan gate sees the rows when it queries them. Required permission: runner token scoped to this run_id.


Onboarding / Resource Discovery

Discover existing, unmanaged cloud resources and generate copy-pasteable resource {} + import {} blocks, then bring them under management with a single gated import run. A session drives a discovery engine: D1 schema discovery runs credential-lessly in the API; D2 query + D3 generate/clean run on the runner; the only mutating step is an operator-confirmed, plan-only import run through the normal run gate.

Onboarding has no feature flag — it is gated per workspace by the workspace:onboard capability (see Roles). Every session endpoint requires workspace:onboard on the workspace, returning 403 otherwise. The optional AI mode (natural-language selection + config cleanup) is the only part behind a switch (api.config.ai_onboarding.enabled); it never alters an attribute value or an import id.

Availability Probe

GET /api/v1/onboarding

Any authenticated user. Reports whether the optional AI mode is available, so the UI can offer the conversational path when it is configured. Onboarding itself is always present — actual access is decided per-workspace by the workspace:onboard capability on the real endpoints.

Response:

{
  "data": {
    "type": "onboarding-availability",
    "attributes": {
      "ai-available": true,
      "ai-model-configured": true
    }
  }
}
AttributeDescription
ai-availableWhether the AI mode switch (ai_onboarding.enabled) is on.
ai-model-configuredWhether AI mode is on and a model is configured.

Create Onboarding Session

POST /api/v1/workspaces/{id}/onboarding-sessions

Requires workspace:onboard. Starts a session and kicks off credential-less D1 schema discovery on a scheduler trigger (off the request thread). The client polls the session (below) until status == "schema_ready", then reads the discovery surface and selects the data-source types to query.

Request body:

{
  "data": {
    "type": "onboarding-sessions",
    "attributes": {
      "provider": "aws",
      "provider-version": "< 6.0"
    }
  }
}
AttributeDescription
providerThe terraform/tofu provider to onboard, lowercase (e.g. aws). One provider per session; onboard several by running several sessions. Invalid → 422.
provider-versionOptional provider version constraint pinned in the generated providers.tf (e.g. < 6.0, ~> 5.0). Empty = unconstrained (latest). Must be a valid terraform version constraint or → 422.

Returns 201 with the serialized session (below).

List Onboarding Sessions

GET /api/v1/workspaces/{id}/onboarding-sessions

Requires workspace:onboard. Returns the workspace's sessions. The (large, per-session) discovery surface is omitted from the list — fetch a single session for it.

Show Onboarding Session

GET /api/v1/onboarding-sessions/{id}

Requires workspace:onboard on the session's workspace. Returns the session including its discovery-surface (served from a time-limited Redis cache — never persisted per-session; an expired session simply re-runs discovery). Once status == "config_ready", the generated config and import blocks are present.

Start Discovery (D2/D3)

POST /api/v1/onboarding-sessions/{id}/discover

Requires workspace:onboard. Dispatches the runner discovery run for a schema_ready session, querying the selected subset of the session's discovery surface. A session not in schema_ready422.

Request body:

{
  "data": {
    "type": "onboarding-sessions",
    "attributes": {
      "selected-types": ["aws_vpcs", "aws_subnets"]
    }
  }
}
AttributeDescription
selected-typesThe data-source types (a subset of the session's discovery surface) to query. Must be a list of strings or → 422.

Returns the serialized session (now with discovery-run-id set).

Session Attributes

The onboarding-sessions JSON:API resource the UI consumes:

AttributeDescription
workspace-idThe owning workspace.
statusSession lifecycle state, one of pending, schema_ready, querying, config_ready, run_created, errored, canceled.
providerThe provider being onboarded (e.g. aws).
provider-versionThe provider version constraint pinned in the generated providers.tf; empty = unconstrained.
engineThe engine D1 ran with (terraform / tofu).
engine-versionThe resolved engine version D1 ran with.
selected-typesThe data-source types selected for the D2 query.
ai-assistedWhether the optional AI mode assisted this session (naming, cleanup, chat). True iff the polished view exists.
discovery-surfaceThe D1 provider-schema surface (from the Redis cache). Present only on the show (detail) read; null on list.
data-source-countCount derived from the discovery surface (null when the surface isn't included).
generated-configD3 output — the cleaned, import-only resource {} config (null/empty ConflictsWith attributes pruned so it plans). Present once status == config_ready.
import-blocksD3 output — the candidate import {} blocks. Present once status == config_ready.
polished-configOptional AI-polished view of generated-config (resources renamed from tags, grouped, commented). Never alters a value or import id (enforced by a value-preservation check). null until the polish lands or if rejected / AI disabled.
polished-import-blocksThe polished counterpart to import-blocks; renames are mirrored deterministically into the to = <addr> targets.
paired-configDerived presentation view (computed at serialize time, never stored): each import {} interleaved directly above the resource it targets, over the canonical generated-config/import-blocks. Ids/values untouched. null until config exists.
paired-polished-configThe same derived pairing over the polished halves. null until the polished config exists.
discovery-run-idThe runner discovery run that produced the query results + config (D2/D3), or null.
result-run-idThe resulting gated import-only run, or null.
errorHuman-readable failure detail when status == "errored".
created-byThe user who started the session.
created-atRFC3339 timestamp.
updated-atRFC3339 timestamp.

Runner Discovery Artifacts

The runner discovery Job uploads its generated artifacts to these endpoints. Auth is a runner token scoped to the discovery run — each endpoint resolves the owning session via its discovery_run_id (== this run), so a Job can only ever write to its own session. Bodies are capped (413 if too large).

PUT  /api/v1/runs/{run_id}/artifacts/onboarding-config

The cleaned, import-only generated resource {} config (D3 + clean). Body is .tf text; stored on the session's generated-config. Returns 204.

PUT  /api/v1/runs/{run_id}/artifacts/onboarding-imports

The candidate import {} blocks (D3). Body is .tf text; stored on the session's import-blocks. Returns 204.

POST /api/v1/runs/{run_id}/artifacts/onboarding-query-results

The raw D2 query results + the import-only verdict (JSON object; non-JSON or non-object → 422). Stored on the session. Returns 204.

Import run is always plan-only: the confirmed import step is a gated, import-only run (plan-only) through the normal run gate — never an auto-apply.

Cost Estimation

Terrapod estimates the monthly cost of Terraform/OpenTofu-managed infrastructure using a native, pure-Python engine over its own self-generated pricesheet — produced by pricegen from official cloud vendor pricing data and published weekly to a rolling GitHub Release. Terrapod ships no binary and shells out to nothing; it downloads the gzipped pricesheet and matches plan/state resources against it in-process.

Every figure returned here is data (engine-derived) — no AI is involved. (An optional AI enhancement — narrative + savings advisories — is a separate surface that rides the plan-analysis AI switch and is always flagged distinctly; it never blends into these authoritative numbers.)

Cost estimation is on by default (Helm: api.config.cost_estimation.enabled, default true); when disabled the endpoints below return 404. The pricesheet is a pull-through cache (mirrored into object storage on demand, no schedule) — air-gapped deployments pre-seed the cached object or point cost_estimation.prices_url at an internal mirror. cost_estimation.default_region is only the fallback for a resource whose region can't be resolved from its own attributes or provider config (region is resolved per resource).

There are two cost views: a run view (the monthly cost delta a plan introduces) and a workspace view (the current monthly cost of the workspace's managed infrastructure, priced from its latest state version).

Show Run Cost Estimate

GET /api/v1/runs/{run_id}/cost-estimate

Requires run:read on the run's workspace. Returns the runner-produced estimate of the plan's monthly cost delta (404 when the run produced no estimate — errored before plan, cost estimation disabled, or the artifact aged out).

Response:

{
  "data": {
    "id": "cost-estimate-<run-uuid>",
    "type": "cost-estimates",
    "attributes": {
      "currency": "USD",
      "total": { "min": 219.0, "max": 219.0 },
      "previous": { "min": 73.0, "max": 73.0 },
      "diff": { "min": 146.0, "max": 146.0 },
      "resources": [
        { "address": "aws_instance.eu", "type": "aws_instance", "name": "eu", "change": "add", "monthly": { "min": 146.0, "max": 146.0 } },
        { "address": "aws_nat_gateway.gw", "type": "aws_nat_gateway", "name": "gw", "change": "add", "monthly": { "min": 37.35, "max": 37.35 },
          "usage_assumptions": [ { "description": "NAT data processed", "dimension": "data processed", "unit": "GB/month", "low": 10, "typical": 100, "high": 50000, "cost_low": 0.45, "cost_typical": 4.50, "cost_high": 2250.0 } ] }
      ],
      "unpriced": [ { "address": "random_pet.name", "type": "random_pet", "change": "add" } ]
    },
    "relationships": { "run": { "data": { "id": "run-<uuid>", "type": "runs" } } }
  }
}
AttributeDescription
currencyPricesheet currency (typically USD).
totalProjected monthly spend of the planned state (min/max range).
previousProjected monthly spend of the prior state (total − diff).
diffMonthly delta this run introduces (adds positive, removes negative).
resources[]Per-resource cost; change is add / remove / noop.
resources[].usage_assumptions[]Present only on resources whose cost depends on runtime usage the plan doesn't declare (data processed, invocations, storage). Each entry is {description, dimension, unit, low, typical, high, cost_low, cost_typical, cost_high}: low/typical/high are the assumed quantity (in unit); cost_low/cost_typical/cost_high are the resulting monthly cost at each. The monthly figure folds in cost_typical; the true cost sits in cost_lowcost_high as usage varies. cost_* are omitted if the band couldn't be priced. Raw data — surfaced independently of the AI layer so the estimate is honest with AI off. Omitted entirely for deterministically-priced resources.
unpriced[]Resources nothing in the pricesheet matched (unmapped/free type, or a provider the data doesn't cover).

Show Workspace Cost Estimate

GET /api/v1/workspaces/{id}/cost-estimate

Requires state:read on the workspace (the estimate is derived server-side from the secret-bearing state blob, though only non-sensitive aggregates are returned). Runs the workspace's latest state version through the cost engine to report the current monthly cost of its managed infrastructure — the state analogue of the run delta above. Because state carries no change, every resource is a noop, diff is zero, and total is the current monthly spend.

A workspace with no state yet (or a state-version row whose bytes haven't landed / were swept) returns a zeroed estimate with state-version: null rather than an error. 503 when no pricesheet is available (upstream fetch failed and nothing is cached).

Response:

{
  "data": {
    "id": "workspace-cost-<workspace-uuid>",
    "type": "workspace-cost-estimates",
    "attributes": {
      "currency": "USD",
      "total": { "min": 292.0, "max": 292.0 },
      "previous": { "min": 292.0, "max": 292.0 },
      "diff": { "min": 0.0, "max": 0.0 },
      "resources": [
        { "address": "aws_instance.web", "type": "aws_instance", "name": "web", "change": "noop", "monthly": { "min": 73.0, "max": 73.0 } }
      ],
      "unpriced": [],
      "state-version": { "id": "sv-<uuid>", "serial": 7, "created-at": "2026-01-01T00:00:00Z" }
    },
    "relationships": { "workspace": { "data": { "id": "ws-<uuid>", "type": "workspaces" } } }
  }
}

The attributes match the run estimate, plus state-version naming the priced version (null when the workspace has no state).

Pricesheet (runner + admin)

GET  /api/v1/cost-estimation/pricesheet             # 302 → presigned cached pricesheet (gzipped YAML) (any authenticated caller; consumed by runner Jobs)
GET  /api/v1/cost-estimation/pricesheet/status      # admin: enabled + cached?
POST /api/v1/cost-estimation/pricesheet/refresh     # admin: force re-fetch from upstream

The download endpoint is pull-through: a cold or stale cache is fetched from cost_estimation.prices_url on demand, and a stale copy is served if a refresh fails (a transient upstream outage never breaks a run). 404 when cost estimation is disabled or nothing is cached; the returned presigned URL needs no auth. refresh returns 502 on an upstream/decompress failure.

AI cost estimate (summary + advisories + chat)

The optional AI layer over the data-only estimate (rides ai_summary.enabled + the per-workspace mode). Its primary output is estimates for the resources the pricesheet couldn't price; secondary are savings advisories and a short narrative. Every dollar figure is tagged source: "ai-estimate", shown separately from the authoritative deterministic total and never summed into it.

GET  /api/v1/runs/{run_id}/cost-summary[?locale=<code>]
POST /api/v1/runs/{run_id}/cost-summary/regenerate

GET returns the summary; 404 until one has been generated (a pending row means "in flight"). With ?locale= set to a real language different from the deployment's ai_summary.summary_language, the narrative + each estimate's basis + each advisory's title/detail are translated on view (best-effort, Redis-cached), and translated/language reflect that. POST .../regenerate re-fires it and returns 202 with a pending row (409 when the run has no cost estimate; 503 when AI is globally disabled).

Response (ready):

{
  "data": {
    "id": "cost-summary-<uuid>",
    "type": "cost-summaries",
    "attributes": {
      "status": "ready",
      "estimated-resources": [
        { "address": "azurerm_storage_account.a", "type": "azurerm_storage_account", "monthly": { "min": 5.0, "max": 8.0 }, "basis": "LRS hot ~100GB", "source": "ai-estimate" }
      ],
      "advisories": [
        { "kind": "reserved", "title": "1-year RI", "detail": "…", "monthly_saving": { "min": 14.0, "max": 21.0 }, "source": "ai-estimate" }
      ],
      "narrative": "The unpriced resources add an estimated …",
      "model": "bedrock/…", "input-tokens": 120, "output-tokens": 60,
      "language": "en", "translated": false,
      "created-at": "2026-01-01T00:00:00Z", "updated-at": "2026-01-01T00:01:00Z"
    },
    "relationships": { "run": { "data": { "id": "run-<uuid>", "type": "runs" } } }
  }
}

status is one of pending / ready / errored / skipped. Advisory kind is one of savings_plan / reserved / spot / rightsizing / other; monthly_saving may be null.

Cost chat

A follow-up Q&A thread grounded in the estimate (the cost analogue of the plan-summary chat), one shared thread per run.

GET  /api/v1/runs/{run_id}/cost-summary/messages[?locale=<code>]
POST /api/v1/runs/{run_id}/cost-summary/messages

GET returns the transcript (cost-summary-messages, chronological; each message translated on view when ?locale= is set, with a per-message translated flag). POST a body of {"data": {"attributes": {"content": "…", "locale": "de"}}} to ask a question and get the synchronous assistant reply (201). Read-on-workspace auth. Error mapping mirrors the plan chat: 409 (per-run message cap hit, or the summary isn't ready), 429 (daily token budget hit), 503 (chat disabled), 400 (empty/oversize body), 502 (model failure — the user turn is still recorded). The model answers from the estimate only and keeps computed-vs-estimated figures distinct.

Service Catalog

No-code self-service provisioning over the private module registry. A catalog item blesses a registry module; provisioning it creates an agent-mode, non-VCS, catalog-managed workspace whose configuration is a server-generated wrapper. See Service Catalog for the full feature doc.

All endpoints below are gated on catalog.enabled (Helm: api.config.catalog.enabled, default true). When the catalog is disabled every endpoint returns 404. The catalog_permission role axis is opt-in (default none), so the feature being enabled grants no access on its own.

Catalog access is governed by a dedicated role axis, catalog-permission (none/read/use/admin, default none, no everyone floor) — see Roles.

Provider Templates

Admin-managed parameterised provider configs rendered into the generated providers.tf. Write requires platform admin; list/show require platform admin or audit.

List Provider Templates

GET /api/v1/provider-templates

Create Provider Template

POST /api/v1/provider-templates

Request body:

{
  "data": {
    "type": "provider-templates",
    "attributes": {
      "name": "aws-standard",
      "provider-type": "aws",
      "body": "provider \"aws\" {\n  region = var.aws_region\n  assume_role { role_arn = var.aws_role_arn }\n}",
      "parameters": [
        { "name": "aws_region",   "type": "string", "description": "Target AWS region" },
        { "name": "aws_role_arn", "type": "string", "description": "Role to assume" }
      ],
      "labels": {"team": "platform"}
    }
  }
}
AttributeDescription
nameUnique display name.
provider-typeThe provider configured, e.g. aws, google, azurerm.
bodyHCL provider body referencing var.*. Rendered verbatim — no server-side interpolation.
parametersDeclared parameters; each becomes a Terraform variable on instances that use this template, surfaced as a provision-form field.
labelsFor label-based RBAC.

Show / Update / Delete Provider Template

GET    /api/v1/provider-templates/{id}
PATCH  /api/v1/provider-templates/{id}
DELETE /api/v1/provider-templates/{id}

Catalog Items

A blessed designation over a registry module. Write (create/update/delete) requires platform admin. List/show require catalog read and are filtered per item to those the caller's role matches.

List Catalog Items

GET /api/v1/catalog-items

Returns only items the caller's catalog-permission matches.

Create Catalog Item

POST /api/v1/catalog-items

Request body:

{
  "data": {
    "type": "catalog-items",
    "attributes": {
      "name": "vpc",
      "display-name": "AWS VPC",
      "description": "Standard VPC with public and private subnets",
      "enabled": true,
      "module-id": "mod-019e0e7b-...",
      "default-version-pin": "1.4.0",
      "provider-template-ids": ["ptpl-019e01db-..."],
      "allowed-agent-pool-ids": ["apool-019e01db-..."],
      "variable-options": [
        { "name": "cidr_block", "description": "VPC CIDR", "default": "10.0.0.0/16" },
        { "name": "environment", "options": ["dev", "staging", "prod"] },
        { "name": "account_id", "hidden": true, "default": "123456789012" }
      ],
      "labels": {"team": "platform"}
    }
  }
}
AttributeDescription
nameUnique slug.
display-nameHuman-facing name shown in the catalog UI.
descriptionFree text.
enabledWhen false, the item is visible but provisioning is rejected with 409.
module-idThe registry module this item wraps.
default-version-pinDefault module-version pin new instances inherit. Omit for float (track latest published).
provider-template-idsProvider templates rendered into the generated wrapper.
allowed-agent-pool-idsPools an instance may bind to. null = any pool the provisioner has write on.
variable-optionsPer-input overlay list (one object per input, keyed by name) curating the module's variables. Each entry supports: options (allow-list, enforced server-side — value outside it → 422, rendered as a dropdown); default (preset, still editable); hidden (fix the value + remove from the form — must include a default, and supplying it returns 422); sensitive (masked, stored write-only). Validated at create/update: a malformed entry, non-list options, or hidden without default422.
labelsFor label-based RBAC (decides which roles see/use the item).

Show / Update / Delete Catalog Item

GET    /api/v1/catalog-items/{id}
PATCH  /api/v1/catalog-items/{id}
DELETE /api/v1/catalog-items/{id}

Delete returns 409 while the item has any instances — destroy or migrate them first.

Provision Form

GET /api/v1/catalog-items/{id}/form

Returns the resolved provision form: resolved-version (per the item's version policy) and fields[] — one field per resolved input (the module's curated variables plus every parameter from the item's provider templates), with type, description, default, sensitivity, and any enum choices. It also returns interface-error, null unless the module version's interface could not be read — in which case the form may be missing variables the module needs, and the reason says why. Required permission: catalog read.

Module Interface

GET /api/v1/catalog-items/{id}/interface

The inputs and outputs of the module version the item resolves to — its default-version-pin, or the latest uploaded version — derived from the module registry rather than stored on the item. Returns resolved-version, inputs[] (name, type, description, default, required, sensitive) and outputs[] (name, description, sensitive): the same entries as the module registry's interface endpoint. Where /form is the curated provision view, this is the module's own surface, and the only place its outputs can be read. All three are null while the module has no uploaded version. interface-error is null unless the version's interface could not be read, when it carries the reason. Required permission: catalog read.

List Item Instances

GET /api/v1/catalog-items/{id}/instances

Required permission: catalog read.

Provision an Instance

POST /api/v1/catalog-items/{id}/provision

Creates a catalog-managed, agent-mode, non-VCS workspace, materialises the inputs as Terraform variables, generates the wrapper, and queues the first run (source catalog).

Request body:

{
  "data": {
    "type": "catalog-instances",
    "attributes": {
      "name": "vpc-prod-us-east-1",
      "agent-pool-id": "apool-019e01db-...",
      "input-values": { "cidr_block": "10.20.0.0/16", "name": "prod", "aws_region": "us-east-1" },
      "version-pin": "1.4.0",
      "auto-apply": false,
      "labels": {"env": "prod"}
    }
  }
}
AttributeRequiredDescription
nameyesWorkspace/instance name.
agent-pool-idyesPool the instance binds to. Caller must have pool write; pool must be in allowed-agent-pool-ids when the item restricts it.
input-valuesyesValues for the form fields (module inputs + provider-template parameters).
version-pinnoPin this instance to a module version. Omit to inherit the item's default-version-pin (or float when that is unset).
auto-applynoAuto-apply runs for this instance.
labelsnoLabels on the created workspace.

Required permission: catalog use and pool write.

ErrorWhen
403Caller lacks catalog use, lacks pool write, or the pool is not in the item's allowed-agent-pool-ids.
409The item is disabled.
422Missing required inputs, or unknown input keys.

Returns 201 with the created catalog-instance (the instance id is the workspace id).

Catalog Instances

A provisioned instance, addressed by its workspace id (wsId).

Show Instance

GET /api/v1/catalog-instances/{wsId}

Required permission: catalog read.

Reconfigure Instance

PATCH /api/v1/catalog-instances/{wsId}

Update the instance's inputs and/or version pin, regenerate the wrapper, and queue a new run (source catalog). This is the only supported way to change a catalog instance's configuration — direct configuration-version uploads and custom-CV runs on a catalog-managed workspace are rejected with 409.

Request body:

{
  "data": {
    "type": "catalog-instances",
    "attributes": {
      "input-values": { "cidr_block": "10.20.0.0/16" },
      "version-pin": null,
      "auto-apply": true
    }
  }
}
AttributeDescription
input-valuesPartial or full input update.
version-pinNew pin, or null to float (track latest published module version).
auto-applyToggle auto-apply for this instance.

Returns a reference to the queued run. Required permission: catalog use.

Destroy Instance

POST /api/v1/catalog-instances/{wsId}/destroy

Queues an is_destroy run with source catalog-lifecycle. On a successful apply of that run the workspace is archived (soft delete — state is retained). Nothing is hard-deleted.

Request body (optional):

{ "data": { "type": "catalog-instances", "attributes": { "auto-apply": true } } }

Returns a reference to the queued destroy run. Required permission: catalog use.

Run sources: catalog runs carry source = "catalog" (provision / reconfigure) or source = "catalog-lifecycle" (destroy → archive). These appear on the run object alongside the existing sources (tfe-api, vcs, run-trigger, drift-detection, autodiscovery-lifecycle, module-test, module-publish, onboarding-discovery).

Confirm / Discard a Catalog Instance Run

POST /api/v1/catalog-instances/{wsId}/confirm
POST /api/v1/catalog-instances/{wsId}/discard

Confirm (apply) or discard the instance's pending planned run. These are the catalog-surface counterparts of the workspace run API: the catalog-managed workspace clamp gives the provisioner only read on the workspace, so a non-auto-apply provision / reconfigure / destroy is confirmed here rather than via /api/tfe/v2/runs/{id}/actions/confirm (which would require a platform admin). Returns the run reference. 409 if there's no planned run awaiting action. Required permission: catalog use.

Orphan Catalog Instance (discouraged)

DELETE /api/v1/catalog-instances/{wsId}?orphan=true

Deletes the catalog instance's workspace record without destroying its infrastructure — the provisioned resources keep running, untracked. This is the explicit, discouraged escape hatch; the recommended teardown is POST .../destroy, which reclaims the infrastructure. The orphan=true flag is required — without it the call returns 409 and points at destroy, so an instance can never be orphaned by accident. The plain DELETE /workspaces/{id} also returns 409 for a catalog-managed workspace. Required permission: catalog admin. Audit-logged. Returns 204.


High Availability

Leader/follower pair endpoints (#960). See High availability.

GET /api/v1/ha/whoami

Unauthenticated by necessity — this is what a node probes against the shared DNS name to discover whether it owns that name, and the probe runs before any trust exists between the two nodes. It discloses only an operator-chosen node name and a role. Cache-Control: no-store, so a CDN or WAF in front of the external name cannot pin leadership to a stale answer.

{"data": {"type": "ha-nodes", "id": "node-a",
          "attributes": {"node-id": "node-a", "role": "leader"}}}

The replication surface is inert until peering is declared. With neither ha.peer.url nor ha.peer.inbound.client_id set, no peer token is issued and none is accepted, so /ha/replication/* refuses every request with 401 — including one bearing a credential left behind by a pairing that was torn down. Withdrawing the configuration withdraws the capability.

GET /api/v1/ha/status

Requires authentication; the in-cluster half additionally requires admin or audit. Whether this node is converging with its peer — the question to answer before moving DNS. Computed entirely from local state, so it still works when the peer is the thing that has broken.

The node's own disposition (role, peer, sync state) is readable by any authenticated user: hiding "you are talking to a follower" from the person whose next write is about to be refused with a 503 is the opposite of useful.

AttributeMeaning
role, node-idThis node's identity and current role
peer-configuredFalse on an ordinary single node; the rest then does not apply
replication-enabledWhether this node pulls from a peer
in-syncThe summary. True only when a pull has succeeded and no class is mid-backfill
last-sync-at, seconds-since-last-syncThe last successful pull. Null when it has never synced
backfilling-classesClasses still being pulled in full. Non-empty means not in sync, however recent the last pull
events-retained, oldest-event-age-secondsLeader side: as the oldest event approaches retention-seconds, the follower is close to having to backfill from scratch
retention-secondsThe retained event window
events-behindHow many events outstanding as of the last successful pull. null is unknown (never pulled, or a peer that does not report it) and is NOT the same as 0, which means caught up
behind-secondsHow old the oldest un-applied change was at that same pull. Null when caught up or unknown
replicated-classesWhat a failover would carry

events-behind needs the peer's latest event id, which the follower cannot derive alone — its page is capped at the batch size, so a full page means "there is more" and nothing about how much more. The leader therefore reports its newest event id (and the timestamp of the oldest event in the page) on every events response, and the follower stores both alongside its cursor. Both figures describe the last successful pull, not this instant — pair them with seconds-since-last-sync to know how old the answer is.

The same response also reports this node's in-cluster HA posture, because a pair that replicates flawlessly is still not highly available if it is serving from one API pod:

AttributeMeaning
components-restrictedTrue when the caller lacks admin/audit. The component fields below are then empty because they were not read, which is deliberately distinct from components-unavailable-reason ("the cluster could not be read") and from an empty list ("nothing is running")
componentsPer component: ready and desired replicas, the nodes and zones they occupy, and the PodDisruptionBudget covering them. API and web come from Kubernetes; listeners come from their Redis heartbeats, since a listener may be in another cluster entirely
single-replica-componentsComponents on exactly one ready replica
schedulable-nodes, cluster-zonesThe spread that was available. Null when the node read is declined or the environment is unzoned — null means unknown, never one
ha-findingsNamed gaps: no-pdb, pdb-blocks-eviction, node-concentration, zone-concentration, single-zone-cluster. Each carries the component and a human-readable detail
components-unavailable-reasonSet when the namespace could not be read. An empty components with a reason set means "I cannot see", never "nothing is running"

An empty ha-findings means nothing avoidable was found — not that nothing was checked. A finding is only raised where the cluster could have done better: a single-node cluster cannot spread replicas and a single-zone one cannot spread zones, so neither is reported as a gap. See High availability.

GET /api/v1/ha/blob-readiness

Requires admin or audit. Are the objects this node's rows name actually in its object store? Replication carries database rows; the object store is a second data plane, and rows present with blobs absent is a node that looks healthy and cannot serve a single run.

QueryDefaultMeaning
fullfalseCheck every row of every class. Thousands of round trips on a real estate, hence opt-in
sample25Newest rows per class when not running full
AttributeMeaning
irreplaceable-missingThe list that should stop a failover — classes whose absence is permanent
irreplaceable-uncheckedIrreplaceable classes this run made no claim about — switched off, or not verifiable from rows. What makes the list above trustworthy
missing-totalMissing objects among those checked
sampledWhether this was a sample rather than a full verification
classes[]Per class: name, tier (irreplaceable|history|rederivable), mode (off|verify|copy), verifiable, note, total-rows, checked, missing, missing-examples (capped), complete, error
unavailable-reasonSet when the store could not be read — "I could not look", distinct from "all is well"
duration-msWhat the check cost

A clean sample is not a clean estate. complete is true only when nothing was held back; otherwise compare checked against total-rows. missing == 0 on a sample means no missing objects among those sampled.

Nor is a clean report a covered store. mode reflects ha.blobs — a class set to off is reported with checked: 0 and a note saying so, never omitted. verifiable: false marks a class no database row guarantees (a run's logs exist only if it got that far; a pull-through cache holds whatever it holds), so presence cannot be derived and the report says as much instead of implying a pass. irreplaceable-unchecked names any of those that matter.

tier is the effective tier for this deployment. On a sealed (registry.cache_only) node the upstream-fed caches report as irreplaceable, because a promoted node with a cold cache cannot reach upstream to re-warm and so can never run anything. Terrapod derives that rather than asking an operator to restate it.

Deliberately not folded into /ha/status: that endpoint is answered from local state in milliseconds and is refreshed freely, while this one does real object-store I/O.

See High availability.

Peer-only endpoints

GET /api/v1/ha/replication/blobs/{class} lists an object-store class's objects (key, size, etag) in key order with after/limit paging, and GET .../blobs/{class}/content?key=… streams one object. Both are how a follower copies the object store (see High availability).

Two properties worth knowing if you are reading the code: the key is checked against the class that owns it before a byte is served — a peer token reads more than a user's, so this must not become an arbitrary-read primitive — and meta.cursor/meta.complete are derived from what the store returned, not from what survived the ownership filter, so a page thinned by the filter is a short page rather than the end of the class.

/api/v1/ha/replication/* are consumed by the peer node, not by users or tooling. They accept a peer token and nothing else, and no other endpoint accepts one — a peer can read entities an ordinary user cannot, so that visibility is deliberately not expressible as roles somebody could be granted.

Deleted Workspaces (undelete)

Deleting a workspace removes its rows but not its state blobs. A delete marker written at delete time keeps that state findable and gives the retention reaper something to age; these endpoints are how an operator sees and recovers it.

The window is finite — once a marker is older than api.config.artifact_retention.deleted_workspace_retention_days (default 30) the state is reaped and the workspace is gone for good. Set the value to 0 to disable reaping entirely.

Platform admin only, on every route including the reads. A marker names a workspace, its labels and its variable names, and a restore materialises its state — and therefore its secrets — into a workspace the caller can then read. The original workspace's ACL died with its rows, so there is nothing to delegate to; it fails closed to admin.

List Deleted Workspaces

GET /api/v1/deleted-workspaces

Newest deletion first. Supports the standard page[number] / page[size].

Notable attributes:

AttributeMeaning
marker-reasondeleted — written by the delete, so deleted-at is the true deletion time. discovered-orphaned — the reaper found state with no marker (deleted before this feature shipped, or the marker write failed), so deleted-at is when it was first seen
state-versions-availableCounted from storage at request time, not taken from the marker — a partial reap or an incomplete replication shows up here and nowhere else
restorable-untilWhen the state becomes eligible for reaping. Empty when retention is disabled
workspace-idThe deleted workspace's id — also the resource id. Accepted bare or ws--prefixed on the routes below
workspace-nameIts name at delete time. null on a reaper-discovered marker, which has no name to record
deleted-at / deleted-byWhen, and by whom. On a discovered-orphaned marker deleted-at is when the reaper first saw it, not when it was deleted
age-daysDays since deleted-at, rounded — what the retention window is measured against
last-serial / lineageThe state's serial and lineage at delete time, so you can tell which workspace's state this is before restoring
settingsThe workspace's non-secret configuration snapshot (labels, owner, execution mode, VCS reference, …), used to rebuild it
restored-toWorkspace ids this deletion has already been restored into; empty until the first restore. Check it before offering a restore — a repeat would produce a second live workspace over the same infrastructure
restored-at / restored-byWhen and by whom the most recent restore happened
variable-namesNames and categories only. Values are never recorded: the marker is a plain object in the bucket and replicates to any standby

Show Deleted Workspace

GET /api/v1/deleted-workspaces/{workspace_id}

{workspace_id} is accepted both bare (0192f3a1-…, as the list emits it) and ws--prefixed, on this route and on the restore below.

Restore a Deleted Workspace

POST /api/v1/deleted-workspaces/{workspace_id}/restore

Optional body: {"data": {"attributes": {"name": "...", "force": false}}}.

name — when omitted the original is reused, suffixed if it has been taken since. It must meet the same format contract as any other workspace name (start with a letter or number; letters, numbers, hyphens and underscores; 90 characters or fewer), because the name is what the cloud {} block matches on and what /app/{org}/{name} redirects by. A name that came from the marker rather than the caller is sanitized instead of rejected, so a hand-edited marker cannot make a recoverable workspace un-restorable.

force — permits restoring a deletion that has already been restored. Without it the request is refused; see below.

This creates a new workspace with a new id and copies the state history into it. It is not an in-place revival, and that is deliberate: re-attaching the original id would need no copy at all and would make deletion a one-keystroke mistake to undo, which makes deletion feel free. Recovery is an explicit operation that yields a visibly new workspace.

Three properties worth knowing before you call it:

  • Lineage and serial are preserved exactly, recovered from inside the state documents. This is the hard correctness constraint — a fresh lineage would make the next apply fail on a mismatch, or treat live infrastructure as unmanaged.

  • It comes back inert. Auto-apply and drift detection are forced off and the VCS connection is not re-attached, whatever the snapshot said. A restored workspace that applied immediately, against infrastructure that may have drifted or been partly torn down since the delete, is the failure this prevents. The response's suppressed list says what to re-enable.

  • Dangling references are dropped, not re-attached. A recorded VCS connection id may since have been reused by a different connection; the response reports it in dropped-references rather than binding to it.

  • Only the newest state versions are copied, bounded by api.config.artifact_retention.state_versions_keep. Anything beyond the cap is listed in state-versions-skipped with a reason rather than dropped silently. Without a bound, one request walks every version ever written through the API pod — decrypt, re-encrypt, put, per document, inside a single open transaction — and a large state exhausts the ingress timeout long before it finishes.

  • A deletion is restored once. A repeat would create a second live workspace holding the same state lineage over the same real infrastructure, after which an apply in either makes the other's next plan read as wholesale drift. The second attempt is refused with a 409 naming the workspace that already exists. Pass force only when that earlier restore is known to be gone.

Response attributes:

AttributeMeaning
nameThe new workspace's name — the original if free, suffixed if taken, or the one you supplied
restored-fromThe deleted workspace's id, so the provenance is on the record
state-versions-restoredHow many were copied
state-versions-skippedEach with a key and a reason — unreadable blob, duplicate serial, or beyond the version cap. A partial-failure signal: a caller that ignores it can believe it recovered history it did not
suppressedSettings forced off (auto_apply, drift_detection_enabled, auto_merge, vcs_connection) — what to re-enable deliberately. What each one was is on the deleted workspace's settings, which the list and show endpoints return: auto_apply is only a boolean projection, so a workspace held at create or create_update reads auto_apply_mode there to put the same guardrail back rather than re-enabling it as always
dropped-referencesThings pointing at other resources that were not re-attached, e.g. a VCS connection whose id may since have been reused

The source prefix and its marker are left untouched, so the original remains recoverable until its window expires.

CodeMeaning
201Restored. The body carries the new workspace id and the suppression report
403Not a platform admin
404No delete marker for that id
409Either no state could be recovered (already reaped) or this deletion has already been restored. Nothing is created; an empty restore is reported as a failure rather than handing back a bare workspace
422The supplied name does not meet the workspace name format

Common Response Codes

CodeMeaning
200Success
201Created
204Deleted (no content)
302Redirect (presigned URLs, binary cache, artifact downloads, OAuth flows)
400Bad request (validation error)
401Unauthorized (missing or invalid token)
403Forbidden (insufficient permissions)
404Not found
409Conflict (lock conflict, duplicate resource, label change would reduce your access)
422Unprocessable entity (semantic validation error)
503Service unavailable (readiness check failed)