Policies

A policy inspects one node's pending change and either passes it or fails it. Policies run at plan time, over the changeset, so a rule that would block an apply shows up in the plan that precedes it rather than half-way through the run.

from atlantide.policy import enforce

enforce("require-tags", keys=["env", "owner"])
enforce("deny-destroy-in-protected", stacks=["prod"])
enforce("require-secret-refs")

enforce() must be called during config evaluation — at the top of the file, outside any Stack block, is the conventional place. Bindings travel with the config: they are recorded in the IR and round-trip through a .atlas artifact, so a plan built from an artifact enforces exactly what the config that produced it did.

Levels

from atlantide.core import PolicyLevel

enforce("require-tags", keys=["env"], level=PolicyLevel.ADVISORY)
Level Effect of a violation
MANDATORY (default) The plan is blocked. plan and apply exit 1.
ADVISORY Reported as a warning; the run proceeds.

Narrowing by type

types= restricts a binding to named resource types. A lone string is one type name, not a sequence of characters:

enforce("require-tags", types="aws.S3Bucket", keys=["owner"])
enforce("require-tags", types=["aws.S3Bucket", "aws.SqsQueue"], keys=["owner"])

Bind the same policy twice with different arguments and each binding is evaluated with its own — one deny-destroy-in-protected per environment works as written.

What a policy sees

Only actionable changes are evaluated; a NOOP node is never passed to a policy. For each one the policy receives the node id, the pending action, the stack, the desired resource, and the binding's arguments. The desired resource is None for a pure DELETE — there is nothing being declared — which is why the resource-inspecting builtins pass such nodes rather than failing them.

Built-in policies

require-tags

Requires the tag keys named in keys, or — with no keys — only that the resource is tagged at all.

enforce("require-tags")                         # any tags
enforce("require-tags", keys=["env", "owner"])  # these keys

A key present with an empty value counts as missing: a blank tag carries nothing. Resource types with no tags field are skipped rather than failed.

require-secret-refs

Every field marked sensitive must hold a SecretRef handle, not a literal. Takes no arguments.

Referencing a secret by name keeps the plaintext out of the config, the IR, state and the .atlas artifact; assigning the value directly puts it in all four. A field declared with secret() is typed SecretRef | None, so pydantic already rejects a literal. This policy covers what the annotation does not — a field declared mutable(..., sensitive=True) on a plain str, which a provider author is free to write and which accepts plaintext silently.

deny-destroy-in-protected

Denies destructive actions — DELETE, and the destroy half of a REPLACE — in the stacks named by stacks.

enforce("deny-destroy-in-protected", stacks=["prod", "shared"])

With no stacks it passes everything: which stacks are protected is the config author's call, so there is no name to assume.

deny-destroy-in-prod is the same policy under an earlier name.

Policy vs. prevent_destroy

Lifecycle(prevent_destroy=True) guards one resource and is checked by the planner; this policy guards every resource in a stack and is checked by the policy pass. They are independent — a resource can be covered by both.

Argument errors

A binding that passes something a policy cannot use fails the plan with a PolicyConfigError naming the policy and the parameter, rather than silently matching nothing:

require-tags: `keys` must be a name or a list of them, got int

Binding a policy to a resource type

Provider authors can attach a policy to a resource type, so every instance carries it without each config having to remember:

from atlantide.policy import policy

@policy("require-tags")
class Gadget(AcmeResource):
    ...

The binding applies to that type alone, and is evaluated alongside whatever the config binds with enforce(). Config itself cannot use this — Atlas-lang forbids class — so it is an API for the Python side: providers, and the libraries that publish components.

Where policies sit in the pipeline

Policy evaluation is the last step of stage 7, after the prevent_destroy guard and after --target/--replace selection — so a policy sees the changeset that would actually run, not the unnarrowed one. See How it works.