Approval policies in YAML and Rego
An approval policy decides what happens to a run before it executes: it runs, it waits for a person,
or it is refused. Policies live in one file named by --policy-file, held in git, so a change to
what needs approval is a reviewed diff. Concepts covers why the file is
the source of truth and how a change deploys.
The file holds two kinds of policy. YAML rules match on criteria. Rego policies are written in the Open Policy Agent language and decide in code, so an existing OPA or Conftest rule ports without being rewritten as criteria. Both are loaded from the same file, reloaded on the same edit, enforced at the same points, and recorded in the same evidence.
Agent runs are held by default
A run an AI agent asked for waits for a person before it executes, with no policy written first.
The hold is built in. It is named requested by an agent, held by default on the held run, in the
approval queue, and in the run's dossier, and the outcome the audit chain commits carries the same
words as a note, so a receipt for the run says why it waited. A person's run is unaffected: the
built-in hold looks only at who asked, and the rules below decide a person's run exactly as before.
Who asked is read from the token, never guessed from the request. The hold applies to agent
tokens, the ones minted with switchtender token new --user <account> --agent. A token minted
without --agent is recorded as a person's and gets no agent hold, so mint every agent's token
with it. A run counts as an agent's when an agent token submitted it, and when it derives from an
agent's request: the apply an agent's plan proposes, a shard of an agent's split, a step of its
workflow, and a retry, a rerun, or a relaunch of failed shards the agent asks for. The derived run
carries the agent's identity, so it faces the same hold. The receipt for the run proves who
approved it or which rule exempted it.
A dry run waits like any other run an agent asks for. Check mode and a plan are not inert: Ansible
runs lookups, vars files, and plugins on the controller under --check, and a Terraform or OpenTofu
plan runs provider code and data sources, all with this server's credentials, so a preview can read
a secret and send it anywhere, even with a request that only reads. Whether a dry run changes
anything is the wrong question for an agent, and no scan can prove a plan safe. The gate still scans
the dry run and records what it read on the run, as evidence, and the hold note says why the run
waits: what the agent asked for runs code with this server's credentials, so it waits for a person
or for an exemption that covers it. A person's dry run is decided exactly as before, under
exclude_dry_run and every other rule.
An agent's Terraform or OpenTofu apply takes two approvals. It is held where it was submitted,
before anything plans, since planning runs provider code with this server's credentials. When a
person releases it, it is planned, and the apply its plan proposes is held again, carrying the saved
plan, so the second approval binds the exact plan that applies. A released request is never applied
without that plan, whatever other rules are in force: a max_destroy limit, a risk or
reversibility floor, or a Rego plan_gate. A rerun, a retry, or a relaunch the agent asks for takes
the same path. An apply an exemption covers plans and applies as the rules allow for a person's.
An agent cannot carry an apply inside a workflow. A workflow's approval binds the workflow as
submitted and never shows the plan a step applies, so an agent's workflow, or a saved workflow an
agent launches, is refused at submission with a 403 when it holds a Terraform or OpenTofu step that
is not a dry run, unless an exemption covers that step. The step is judged as the run it would
become, under the agent's label and account. The refusal names the step, is recorded on the chain
under the name an agent's workflow may not apply Terraform or OpenTofu, and says what to do
instead: ask for the apply as its own run, which plans first and waits for approval of the saved
plan, or write an exemption that covers the step. An exemption that covers the step lifts the
refusal and nothing more: the workflow is still held for a person as a whole, unless an exemption
also covers the workflow run itself. This holds however the workflow arrives: submitted
whole, launched from a saved workflow, or launched through an MCP tool. A finished workflow is never
rerun as such, by anybody, only launched again from its saved workflow, which faces the same check.
An agent's workflow whose infrastructure steps are all plans, or that has none, is held as a whole as
before. A person's workflow is unchanged.
SwitchTender knows an agent by its agent token, and only by that. A caller that signs in with a federated JWT is treated as the person or pipeline its claims map to: it is recorded as a session, with the role its group claim maps to, and gets no agent hold. An AI agent that reaches SwitchTender through a workload JWT is therefore not held by default, and if its claims map to admin, it can approve. Give every AI agent an agent token, and never a JWT mapped to a role that can approve.
The built-in hold sits beside the stored rules and replaces none of them. A deny rule still refuses an agent's run outright, and a stored rule that holds the run is the one the hold names, with its distinct-approver and reason requirements. Releasing an agent's run works as it always did: a person with the admin role approves it, and an agent never can.
Exempting an agent's routine work
An exemption is a stored rule with effect: exempt. The agent runs it matches go ahead without the
built-in hold:
policies:
- name: release-agent-smoke-tests
effect: exempt
actor: release-agent
account: release-bot-owner
tool: bash
queue: staging
command_contains: ./smoke.sh
An exemption matches on tool, command_contains, inventory_id, queue, actor, and
account, and actor_kind may be left out or set to agent. account is the username of the
account the agent's token is bound to, the one named by --user when the token was minted. An
exemption that names actor must also name account, and one that does not is refused with a 400
through the API and fails to load from the policy file: an actor is a token's label, chosen by
whoever mints the token and not unique across accounts, so the label alone would also exempt a
token minted for another account under the same name. An exemption may name account alone, which
covers every agent token bound to that account. It is refused with min_risk, reversibility,
exclude_dry_run, require_distinct_approver, require_reason, or max_destroy, which belong on
a rule that holds. It lifts the built-in hold and nothing else: a stored rule that holds or refuses
the same run still does.
The account travels with every run derived from the agent's request: the apply its plan proposes, the shards of its split, the steps of its workflow, and a retry, a rerun, or a relaunch it asks for. A run whose account cannot be read is never exempt.
An exemption is a rule like any other. GET /v1/policies lists it, account included. The rule set
every run records as in force covers it and describes it as "lets an agent's run proceed without the
default hold, for agents bound to account "release-bot-owner"". A run it lets through records the
note requested by an agent bound to account "release-bot-owner", exempt from the default hold by policy "release-agent-smoke-tests" in its outcome, so the receipt and the dossier name the account
and the exemption. Write one through the API or the policy file, the same as any rule.
The built-in hold is not a stored policy, so it cannot be deleted. An exemption is a Community rule,
one that names an actor and an account included, and a Community install holds it beside as
many plain rules as it needs.
What an exemption risks is everything it matches, which then runs with no person looking. Keep it narrow.
command_containsmatches text anywhere in the command, ignoring case, so an exemption forsmokealso matchessmoke; rm -rf /. Pair it withtool,queue, orinventory_id.- An admin can mint a token for any account, so the exemption trusts whoever can mint tokens. Bind each agent to an account of its own, and an exemption naming that account covers that agent alone.
- An exemption with no criteria exempts every agent run, which turns the default off.
Prefer a named actor and its account together with a queue or an inventory that reaches only
what the agent's routine work needs.
Nothing an agent writes runs later as someone else
An agent's token is capped at the operator role, and creating or editing a schedule, a trigger and its webhook, a template, a workflow, an inventory, a project, a credential, or a policy is admin work. A manage grant does not open any of it to an agent either, since a grant is delegation between people. So nothing an agent writes fires a run later under a schedule's or a webhook's name.
Everything an agent can launch faces the hold under the agent's own name: a run, a split, a workflow, a template launch, a drift reconcile, a rerun, a retry, a relaunch of failed shards, a tool call through MCP, and the apply a plan proposes, on the control node or on a relay worker. A pull request comment acts for the person whose forge account is linked, and an agent cannot link one.
Upgrading
An existing install gets the built-in hold when it upgrades. There is no migration and no setting. An agent whose runs went ahead unattended before the upgrade now waits for a person, its dry runs included, and its Terraform or OpenTofu applies take two approvals. Write an exemption for the routine work that should keep running unattended, and leave everything else held.
YAML rules
policies:
- name: prod-terraform-destroy
tool: terraform
command_contains: destroy
- name: large-teardown
tool: opentofu
max_destroy: 5
- name: agents-never-drop-databases
actor_kind: agent
command_contains: drop database
effect: deny
A rule matches on tool, command_contains (ignoring case), inventory_id, queue, actor_kind,
actor, account, min_risk, reversibility, and exclude_dry_run. account is the username of
the account the requesting credential is bound to: the person behind a token or a session, or the
account an agent's token acts for. A run whose account cannot be read is matched by a rule that
holds or refuses, and never by an exemption. A match holds the run, refuses it with
effect: deny, or, with effect: exempt, lets an agent's run go ahead without the
built-in hold. require_distinct_approver makes the release need
someone other than the requester, and max_destroy plans a Terraform or OpenTofu apply first and
holds it only when the plan destroys more than that many resources.
The count is read from the plan file the plan saved, and the apply that follows carries out that saved plan, so the plan an approver releases is the plan that runs. If the infrastructure changed since the plan was made, the tool refuses the stale plan rather than applying a different one, and the apply has to be proposed again. The Terraform page has the details.
A rule with no max_destroy that would hold a Terraform or OpenTofu apply plans it first as well.
The apply is planned, the apply the plan proposes carries the saved plan, and the rules decide on that
proposal, so the rule holds the planned apply and the approval binds the plan that runs rather than a
request that would plan again when released. An apply whose own submission asks for approval is
planned first as well, and the apply its plan proposes is held whether or not a rule covers it.
What a workflow's approval binds
A workflow is approved as a whole, and the approval binds the workflow as it was submitted. The decision commits a digest of its spec: every step as written, with its tool, its command or playbook, its inventory, whether it is a dry run, and the steps it depends on, along with the workflow's variables, the digests of its secret answers, its credentials, its image, and the inventory snapshot it was submitted with. A workflow held while it reads from a project is pinned to the commit its branch pointed at when it was held, and every step runs that commit.
What a step works out only when it runs is not part of that approval. A Terraform or OpenTofu apply step plans and applies when the step runs, so the plan it applies is never shown to the person who approves the workflow. To approve an exact plan, run the apply on its own, held for approval by a rule or by asking for approval when it is submitted: it plans first, and the approval binds the saved plan. An agent's workflow cannot carry an apply step at all, as Agent runs are held by default describes.
Requiring the approver's reason
require_reason makes a decision on a run the rule holds carry the approver's stated reason.
denials requires one to deny, and always requires one to approve or to deny. Left out, a reason
is optional.
policies:
- name: prod-terraform-destroy
tool: terraform
command_contains: destroy
require_reason: always
- name: hold-everything
require_reason: denials
When several rules cover one run, the strictest requirement applies. It is copied onto the run when
the rule holds it, the way require_distinct_approver is, so editing the rule later cannot loosen a
decision already waiting. A workflow's approval steps carry the requirement of the rules covering the
workflow and its steps. A decision without a required reason is refused with the rule named, and
nothing is recorded. The reason itself is masked, stored, and committed as described in
Approver reasons.
Rego policies
A Rego policy is listed under rego: in the same file. Its modules are read from paths relative to
the policy file, so the YAML and the Rego it loads change together in one diff.
policies:
- name: prod-terraform-destroy
tool: terraform
command_contains: destroy
rego:
- name: guardrails
files: [rego/guardrails.rego, rego/lib.rego]
# package: data.switchtender # optional, defaults to the first file's package
# syntax: v0 # optional, for modules written before OPA 1.0
# warn: note # optional, record warnings instead of holding
# timeout: 2s # optional, 500ms unless set, at most 10s
| Key | Meaning |
|---|---|
name |
Names the policy in holds, refusals, and the evidence. Unique across the file. |
files |
One or more modules, relative to the policy file. Helper packages may sit beside the decision package and be imported from it. |
package |
The package whose rules decide. Defaults to the package the first file declares. |
syntax |
v1, the default, or v0 for the deny[msg] { ... } syntax most Conftest policies are written in. |
warn |
What the package's warn rule does: hold, the default, holds the run for approval, and note records the warning on the run and lets it go ahead. See Warnings: hold or note. |
timeout |
How long one evaluation may run, as a duration with a unit such as 750ms or 2s. 500ms unless set, at most 10s. See Evaluation time limit. |
require_reason |
denials or always, the same setting a YAML rule takes. It applies to every run this policy holds. |
Editing a module takes effect without a restart, exactly as editing the YAML does.
What a policy decides
The decision package may define any of these rules. Every other rule in it is a helper.
| Rule | Shape | Effect |
|---|---|---|
deny |
set of messages, or boolean | Any message refuses the submission. The run is never created. |
allow |
boolean | When the package defines it, anything it does not allow is refused. |
hold |
set of messages, or boolean | Any message holds the run for approval. |
warn |
set of messages, or boolean | Read as hold by default, since a warning asks a person to look and here a person looks by approving. Under warn: note it is recorded on the run instead, and the run goes ahead. |
require_distinct_approver |
boolean | Whoever releases the run cannot be whoever asked for it. |
plan_gate |
boolean | A Terraform or OpenTofu apply is planned first, and the apply the plan proposes is judged again with input.plan filled in. |
A message is a string, or a Conftest result object, whose msg field is used. A package must define
at least one of deny, allow, hold, or warn, or it is refused at load.
The decisions map onto the YAML model one for one. A deny behaves as a rule with effect: deny, a
hold as a plain rule, require_distinct_approver as the YAML flag of the same name, and plan_gate
with a hold on input.plan.destroys as max_destroy. Every deny is checked across the whole file
before any hold, so a refusal in Rego is not satisfied by a hold in YAML, or the reverse.
package switchtender
import data.lib
deny contains "agents may not touch production" if {
input.actor.kind == "agent"
lib.prod
}
hold contains "irreversible change" if input.reversibility.class == "irreversible"
require_distinct_approver if input.reversibility.class == "irreversible"
plan_gate if input.run.tool in {"terraform", "opentofu"}
hold contains msg if {
input.plan.planned
input.plan.destroys > 5
msg := sprintf("plan destroys %d", [input.plan.destroys])
}
Warnings: hold or note
A warn rule asks a person to look. By default a warning holds the run for approval, the same as
hold. A policy that sets warn: note records its warnings instead of waiting on them.
warn |
What a warning does |
|---|---|
hold (default) |
The run waits for approval. The hold names the policy and the warning. |
note |
The run goes ahead. The warning is recorded on the run as a note: the run page lists it, the runs list marks the run as noted, the dossier includes it, and the outcome the audit chain commits carries it, so a receipt for the run shows it. |
A note names the policy, its messages, and its bundle the way a hold does, for example
staging-advice (no change ticket on the run, rego sha256:3f9c0a1b2c4d). It carries at most ten
messages and says how many more there were. Before a note or a hold is recorded, values the run's
variables and script hold under secret-looking names are masked in it, and so is any
secret-looking assignment it quotes, since a module can build a message from input.run.command.
The setting covers warn and nothing else.
- A
denyrule in the same package still refuses, and aholdrule still holds. A run one of them holds still carries the notes, so the approver sees them. - A policy that cannot decide, because it errors or runs past its timeout, refuses the submission under either setting. Deciding nothing is not a warning.
- Setting
warn: noteon a package with nowarnrule is refused at load. It would read as softening the rules that are there while changing nothing.
A dry run the scan did not find change free is judged twice, as described below, and a policy that notes records what either pass warned about.
Per project: staging notes, production holds
The setting belongs to a policy entry, so two projects get different treatment from two entries. The entries below share one set of checks. One decides only about the staging project and notes, the other decides only about production and holds:
rego:
- name: staging-advice
files: [rego/staging.rego, rego/checks.rego]
warn: note
- name: production-advice
files: [rego/production.rego, rego/checks.rego]
rego/checks.rego writes each check once:
package checks
findings contains "no change ticket on the run" if not input.run.labels.ticket
findings contains "an agent asked for this run" if input.actor.kind == "agent"
rego/staging.rego turns them into warnings for the staging project:
package staging
import data.checks
warn contains msg if {
input.run.project_id == "proj_5d0e1a2b3c4f"
some msg in checks.findings
}
rego/production.rego does the same for production, and its entry leaves warn at the default, so
each warning holds the run:
package production
import data.checks
warn contains msg if {
input.run.project_id == "proj_9a8b7c6d5e4f"
some msg in checks.findings
}
A person's staging run without a ticket label goes ahead carrying the note
staging-advice (no change ticket on the run, ...). The same run in production waits for approval,
held by production-advice. A run in any other project is decided by neither entry.
GET /v1/projects lists each project's id.
An agent's run is held by default whatever these entries decide,
unless a rule with effect: exempt covers it, and it still carries the staging note, so the
approver sees it.
The same shape scopes a warning to one job instead of a project. A rule reads the playbook the job
runs, input.run.playbook, or input.run.template_id, which names the template however the run was
fired, a schedule and a trigger included. input.run.source and input.run.source_id say what fired
it: the template when it is launched directly and the schedule when a schedule fires it.
Making a rule blocking or silent
Where a finding lands depends on the rule that emits it and the entry around it:
| To make a finding | Emit it from |
|---|---|
| Refuse the submission, so the run is never created | deny |
| Hold the run until someone approves it | hold, or warn in an entry that leaves warn at hold |
| Let the run go ahead and keep a record of it | warn in an entry that sets warn: note |
| Say nothing at all | none of deny, hold, or warn |
To make a finding fully blocking in production, emit it from deny in rego/production.rego. The
submission is then refused with the finding as the reason, and no approver can release it:
package production
import data.checks
deny contains msg if {
input.run.project_id == "proj_9a8b7c6d5e4f"
some msg in checks.findings
}
To silence one finding for staging, leave it out of the rule that reads the checks. The other finding is still noted:
package staging
import data.checks
warn contains msg if {
input.run.project_id == "proj_5d0e1a2b3c4f"
some msg in checks.findings
msg != "an agent asked for this run"
}
To silence a whole entry, remove it from the policy file. Emptying its package instead does not
work: a package that defines none of deny, allow, hold, or warn is refused at load, the same
as any other policy that would decide nothing.
The input document
Every policy is evaluated against the same document, version 1. Every field is always present, with an empty value when the run has none, so a module never meets a missing field.
| Field | Meaning |
|---|---|
version |
1. It changes only if a field is removed or changes meaning. |
run.id |
The run's id. |
run.tool |
ansible, bash, terraform, opentofu, python, powershell, or go. Never empty. |
run.command |
The script, or the working directory for Terraform and OpenTofu. |
run.playbook |
The Ansible playbook path. |
run.inventory.id |
The stored inventory the run targets, empty for a path. |
run.inventory.path |
The inventory path the run targets. |
run.limit |
The host pattern the run is narrowed to. |
run.queue |
The worker queue the run is routed to. |
run.project_id |
The git project the run reads from. |
run.dry_run |
Whether the run was asked for as a no-change preview: the tool's dry-run flag, --check for Ansible. |
run.dry_run_findings |
For a dry run, why the gate's scan did not find it change free. For Ansible, each play, block, task, role, or include that sets check_mode to anything but true, and each play or task that runs a pipe lookup, naming the file. For Terraform and OpenTofu, each external or aws_lambda_invocation data source, and each http data source with a write method or a request body, by its address, naming the file and line. For both, whatever the scan could not read. Empty for a dry run read in full that found none of these, for a dry run of a tool nothing scans, and for any other run. |
run.change_free |
Whether the gate reads the run as a dry run that changes nothing: dry_run is true, dry_run_findings is empty, and the tool is one the gate scans or a built-in one whose dry run runs nothing. A dry run of a tool a plugin or the SDK added is never change free. |
run.kind |
Empty for a plain run, or split or pipeline for a coordinator. |
run.step_name |
The pipeline step's name, for a step. |
run.source |
What fired the run: api, template, schedule, trigger, callback, rerun, relaunch, reconcile, propose, review for a pull request plan, or review_apply for an apply a pull request comment proposed. |
run.source_id |
The template or schedule behind run.source, the origin run of a rerun, or the plan run a comment's apply was proposed from. |
run.template_id |
The job template the run executes, however it was fired: launched directly, by a schedule, by a trigger, or by a callback, and carried to a rerun, a retry, a shard, and a pipeline step. Empty for a run no template launched. |
run.proposed_from |
The plan run an apply was proposed from, empty otherwise. |
run.intent |
The plain-language request an AI proposal was built from. |
run.image |
The container image named on the run. |
run.tags, run.skip_tags |
The Ansible tags selected and skipped. |
run.labels |
The run's labels, for example run.labels.env. This is where the environment usually lives. |
run.extra_var_names |
The names of the extra variables, sorted. Values are withheld, since they are where secrets ride. |
run.credential_ids |
The stored credentials the run executes with, by id. |
actor.name |
The token label or username that fired the run. For an agent, its token's label. |
actor.type |
How it authenticated: agent, session, token, cli, forge_comment for a person commenting from the forge account linked to their SwitchTender account, webhook, or host for a provisioning callback. |
actor.kind |
agent, human for a session, a person's token, the command line, or a forge comment, or other for a webhook, a schedule, a callback, or an unknown source. |
actor.account_id |
The id of the account behind the credential. For an agent, the account it acts for. A YAML rule's account matches the account's username instead. |
plan.planned |
Whether a plan has been read for this apply. |
plan.destroys |
How many resources the plan destroys, or null when nothing was planned. |
risk.level |
low, medium, or high, the same grade min_risk reads. |
risk.reasons |
Why the run graded that way. |
reversibility.class |
reversible, costly, or irreversible, the same grade reversibility reads, including what a playbook's content shows. |
reversibility.reasons |
Why the run graded that way. |
time.submitted_at |
When the run was submitted, RFC 3339 in UTC. |
time.submitted_at_ns |
The same instant in Unix nanoseconds, for time.* builtins. |
A reference to a field outside this document, such as input.run.toool, is refused at load. In
Rego a missing field is undefined and an undefined rule never fires, so the typo would otherwise load
cleanly and refuse nothing. The check covers references spelled from input. A module that copies
input into a variable is checked only as far as the copy.
A policy that reads input.plan must define plan_gate, or it is refused at load, since without
it no apply is planned and the count it reads would never exist.
Failing closed
A Rego policy that cannot decide is a refusal, never a pass, the same rule the YAML engine follows for a policy set it cannot read.
| When | What happens |
|---|---|
| A module does not parse or compile | The file does not load. A server refuses to start, and a running one refuses every submission until the file is fixed. |
| A module calls a disabled builtin | The same, since a call to an undefined function does not compile. |
| A reference names a field the input lacks | The same. |
| Evaluation errors, including a builtin error, which evaluation surfaces rather than hides | The submission is refused, naming the policy, the error, and the bundle. |
Evaluation runs past the policy's timeout, 500 milliseconds unless it sets one |
The same, naming the limit. |
A decision rule has the wrong shape, such as deny := 5 |
The same. |
allow is defined and does not evaluate to true |
The submission is refused as not allowed. |
A policy that cannot decide also demands a distinct approver and plans any apply, so no question the dispatcher asks it is answered in the permissive direction.
Evaluation time limit
Each evaluation of a Rego policy has a time limit, 500 milliseconds unless the policy's entry sets
timeout:
rego:
- name: inventory-checks
files: [rego/inventory.rego]
timeout: 2s
The value is a duration with a unit, such as 750ms, 2s, or 1.5s, and may be at most 10s. A
value over 10s, a value of zero or less, and a number without a unit are refused at load, the
same as a module that does not compile. A bare 500 is refused rather than guessed at, since it
could mean milliseconds or seconds.
A policy that runs past its limit has not decided, so the submission is refused, and the refusal names the policy, the limit, and the bundle:
inventory-checks (rego policy: evaluation exceeded its 2s timeout, which the policy's timeout setting can raise to at most 10s, rego sha256:3f9c0a1b2c4d)
The limit applies to each evaluation, and one submission can evaluate a policy several times. The gate asks it separately whether to refuse, whether to hold, what to note, whether a second approver is needed, and whether to plan an apply first, and a dry run the scan did not find change free is evaluated twice for each. A person is waiting on every one of them, so a policy that needs more than the default is worth making cheaper before its limit is raised.
Changing timeout changes which evaluations refuse, so it changes the rule set digest every later
run records, and a timeout other than the default is named in the rule's description in the
evidence.
Offline and deterministic
Evaluation never leaves the process. Every builtin OPA marks nondeterministic is removed, and so is
anything that reaches outside the input: http.send, net.lookup_ip_addr, opa.runtime,
time.now_ns, rand.intn, uuid.rfc4122, io.jwt.decode_verify, the certificate chain
verifiers, providers.aws.sign_req, and trace. The network allow list is empty. Pure builtins,
including net.cidr_contains and the rest of the net.cidr_* family, stay available. A module that
needs the time reads input.time, so the same run gives the same answer on every evaluation and on
every server.
Evidence
Each Rego policy is identified by a bundle digest: the SHA-256 over its package, its syntax, and every module's path and text. The digest is in four places.
- A held run records the policy, its reasons, and the first twelve characters of the digest, for
example
guardrails (irreversible change, rego sha256:3f9c0a1b2c4d). - A run a policy noted records each note, named the same way, in
policy_notes. The notes are part of the run's outcome entry in the audit chain, so a receipt for the run carries them andswitchtender verifyprints each one as a policy note. - A refused submission names the same in its error, and the audit chain records the refusal as a
decision entry by
system:policyon behalf of whoever asked, at/runs/<id>/decision/refused/rego/sha256:<64 hex>/policy/<name>. The run is never created, so this entry is what shows afterward which bundle turned the submission away. A refusal by a YAML deny rule is recorded the same way, at/runs/<id>/decision/refused/policy/<name>. - Every run records the rule set in force at submission, and a Rego policy appears in it as
guardrails: decided by Rego package data.switchtender, bundle sha256:<64 hex>. That record is part of the run's outcome entry in the audit chain, so a receipt for the run proves which exact bundle was in force, and an auditor can check out the commit, hash the modules, and compare. A policy that setswarn: note, or atimeoutother than the default, is described with the setting after the digest, for examplestaging-advice: decided by Rego package data.staging, bundle sha256:<64 hex>, warnings noted without holding, timeout 2s.
Any edit to a module changes the digest, and so changes the rule set digest every later run carries.
Changing warn or timeout leaves the bundle digest as it is, since no module changed, and changes
the rule set digest, since the same bundle now does something different to a run. Renaming the
policy changes neither.
Workers
A relay worker reads the control node's policies, Rego bundles included, and compiles each one
again on arrival, refusing one whose modules do not produce the digest they arrived under. A worker
that cannot compile a bundle fails closed exactly as one that cannot reach the control node does.
Each bundle arrives with its warn and timeout settings, and a setting the worker cannot honor
fails the same way, so the worker's copy of a policy decides exactly as the control node's does.
License
A Rego policy is part of the full policy engine, which Team covers, the same tier as actor scoping and separation of duties. Deny rules and risk and reversibility floors are Pro. A Community or Pro install whose policy file lists a Rego policy refuses to load it and names the policy and the tier, the same refusal it gives a distinct-approver rule. A lapsed Team license keeps serving the policies it was serving.
Rego policies are read only from the policy file. The API does not create them, and a backup carrying one is refused at restore.
Dry runs that are not change free
A dry run is a promise about the tool, not about what it is given. An Ansible dry run is
ansible-playbook --check, and a playbook can set check_mode: false on a play, block, task, role,
or include to run that work for real anyway, and a pipe lookup runs its command on the controller
whenever a template renders, under --check too. A Terraform or OpenTofu dry run is plan, and a
plan reads every data source with the run's credentials: an external data source runs the program
it names, an aws_lambda_invocation data source invokes its function, and an http data source
sends its request. The gate reads the playbook, or the configuration and every module it calls,
before it judges a dry run, and records what it found in run.dry_run_findings:
- Each task that forces real work with
check_mode, by its file. - Each
pipelookup in a play'svarsorenvironmentor anywhere in a task, written aslookup,query, orq, with or without theansible.builtin.prefix, or as awith_pipeloop, by its play or task and file. - Each
externaloraws_lambda_invocationdata source, and eachhttpdata source whose method is anything but GET or HEAD or that carries arequest_body, by its address, file, and line. A method the scan cannot read as a plain value counts as a write.
Input the scan could not read is recorded there too, because unread input can act for real as well: a role that is not in the project, a registry module the gate could not download, a module source only known at run time, or a file that does not parse. A dry run of a tool a plugin or the SDK added is never change free, since the plugin runs its dry run as it was coded and nothing scans it.
The scan finds the known ways a check or a plan acts. It does not prove a preview harmless: other
lookups and plugins run code on the controller under --check, a module that claims to support
check mode can act anyway, and a plan runs provider code, all with the run's credentials. A rule
that exempts dry runs trusts the playbooks and configurations it lets through, so keep it to code
your team reviews. An agent's dry run is held by the built-in
hold whatever the scan finds, unless an exemption covers it, and
the scan is recorded on it as evidence.
A dry run that is not change free is evaluated twice: once with the input as it is, and once as the
real run it may be, with run.dry_run false. The stricter answer stands: every deny, every hold, a
demand for a distinct approver, and a plan gate from either pass. A module that exempts previews by
reading run.dry_run, the way a YAML rule sets exclude_dry_run, therefore holds such a dry run
exactly where the YAML rule does, and lets a change-free dry run through exactly where the YAML rule
does. A module that wants to say so directly reads run.change_free:
hold contains "a change in production waits for a person" if {
input.run.labels.env == "prod"
not input.run.change_free
}
When the scan is the only reason a rule holds a dry run, the run carries a hold note naming what
was found and the two clean fixes: rework it so the dry run is safe, or drop the exemption from the
rule. Dry runs and exclude_dry_run walks through the
common cases.
Testing a policy locally
The input is plain JSON, so OPA's own tools test a module before it is merged. Save a sample run as
input.json:
{
"version": 1,
"run": {
"id": "run_sample", "tool": "terraform", "command": "infra/prod", "playbook": "",
"inventory": {"id": "", "path": ""}, "limit": "", "queue": "prod", "project_id": "",
"dry_run": false, "dry_run_findings": [], "change_free": false, "kind": "",
"step_name": "", "source": "api", "source_id": "", "template_id": "",
"proposed_from": "", "intent": "", "image": "", "tags": [], "skip_tags": [],
"labels": {"env": "prod"}, "extra_var_names": [], "credential_ids": []
},
"actor": {"name": "deploy-bot", "type": "agent", "kind": "agent", "account_id": "usr_1"},
"plan": {"planned": false, "destroys": null},
"risk": {"level": "high", "reasons": []},
"reversibility": {"class": "costly", "reasons": []},
"time": {"submitted_at": "2026-10-01T12:00:00Z", "submitted_at_ns": 1790856000000000000}
}
Then evaluate the decision package:
opa eval --format pretty -d rego/ -i input.json data.switchtender
Conftest runs the same modules with conftest test --namespace switchtender -p rego/ input.json,
reading deny and warn. The answer opa eval gives is the answer the server gives, with one
difference to keep in mind: the server refuses a module that calls a disabled builtin or reads a
field outside the document, and opa eval checks neither.
Moving from Sentinel
Sentinel is a proprietary language with no open implementation, so a Sentinel policy cannot be loaded. Its rules translate directly into Rego. A Terraform Cloud policy that refuses applies destroying more than five resources outside a change window:
import "tfplan/v2" as tfplan
import "time"
destroys = filter tfplan.resource_changes as _, rc {
rc.change.actions contains "delete"
}
in_window = time.now.weekday_name in ["Saturday", "Sunday"]
main = rule {
length(destroys) <= 5 or in_window
}
becomes:
package switchtender
plan_gate if input.run.tool in {"terraform", "opentofu"}
weekend if {
day := time.weekday(time.parse_rfc3339_ns(input.time.submitted_at))
day in {"Saturday", "Sunday"}
}
deny contains msg if {
input.plan.planned
input.plan.destroys > 5
not weekend
msg := sprintf("plan destroys %d outside the weekend window", [input.plan.destroys])
}
Two differences matter. Sentinel reads the clock, and Rego here reads the submission time from the
input, so the answer does not change between evaluations. And Sentinel walks the full plan, while
the input carries the plan's destroy count, which is what a destroy threshold reads. A rule that
needs individual resource changes from the plan is a case for hold, so a person reads the plan,
until the input carries more of it.
