# Atlantide
> Typed, deterministic Infrastructure-as-Code for Python.
Atlantide is a typed, deterministic Infrastructure-as-Code engine for Python.
Configs are ordinary Python files (`infra.py`) executed by Atlas-lang, a bounded
interpreter with no clock, randomness, environment, network or filesystem, so
the same config always produces the same plan. Resources form a Merkle-hashed
dependency graph; apply skips any node whose hash is unchanged without calling
the provider.
# Atlantide
**Typed, deterministic Infrastructure-as-Code for Python.**
Infrastructure is written in Python and checked by your IDE and `mypy`. The same
config always produces the same plan, and the engine verifies this.
**uv**
```bash
uv tool install atlantide
```
**pipx**
```bash
pipx install atlantide
```
**pip**
```bash
pip install atlantide
```
**Binary (no Python)**
```bash
# Linux x86-64; macOS and Windows builds are on the Installation page
curl -L -o atlantide \
https://github.com/atlantide-org/atlantide/releases/latest/download/atlantide-linux-x86_64
chmod +x atlantide && sudo mv atlantide /usr/local/bin/
```
The package requires Python 3.12 or newer. The standalone binary bundles its own
runtime but cannot load third-party provider plugins. See
[Installation](https://atlantide-org.github.io/install.md) to choose between them.
> **Using a coding agent?**
>
> Point it at [`llms.txt`](https://atlantide-org.github.io/llms.txt), or add
> the [agent guide](https://atlantide-org.github.io/agents.md) to your project so it writes valid Atlas-lang
> and knows which commands are safe to run.
## Quickstart
`atlantide init` scaffolds a working project. The default `minimal` template uses
the `local` provider and needs no cloud credentials:
```bash
mkdir demo && cd demo
atlantide init
```
```
created demo/.gitignore
created demo/atlantide.toml
created demo/infra.py
ok infra.py — 1 resource(s), no cycles
next: atlantide plan
```
```bash
atlantide apply -y # create it
atlantide plan # again: unchanged
```
```
+ create local.File:greeting
Plan: 1 to add
...
Applied: 1 to add (0.0s)
```
```
= noop local.File:greeting
Plan: 1 unchanged
```
The second `plan` compares recorded hashes with freshly computed ones and calls
no provider. `atlantide init --template aws` scaffolds the same project for AWS.
## A quick look
```python
from atlantide.core import Config, EnvSchema, Stack, output
from atlantide.policy import enforce
from atlantide.providers.aws import S3Bucket, SqsQueue
enforce("require-tags", keys=["env"])
enforce("deny-destroy-in-protected", stacks=["prod"])
class AppEnv(EnvSchema):
versioning: bool = False
config = Config(
AppEnv,
envs={
"dev": {"region": "eu-north-1", "tags": {"env": "dev"}},
"prod": {"region": "eu-north-1", "tags": {"env": "prod"}, "versioning": True},
},
)
for env in config.envs(): # env: AppEnv
with Stack(env.name, config=env, name_prefix="atlantide"):
assets = S3Bucket("assets", versioning=env.versioning)
jobs = SqsQueue("jobs", fifo=True)
output("assets_arn", assets.arn)
```
```bash
atlantide plan infra.py # preview, every environment
atlantide apply infra.py # reconcile, in parallel
atlantide apply infra.py # again: all NOOP, zero provider calls
atlantide apply infra.py --env prod # prod only; dev is not diffed or touched
```
One [`Config`](https://atlantide-org.github.io/reference/configuration.md#environments) holds every environment
and what differs between them. Each variable declares a type and an optional
default, so a missing or mistyped prod value fails `atlantide validate`, not the
prod apply. `Stack` reads the well-known keys `region`, `tags` and `name_prefix`
directly. Declaring the shape as an `EnvSchema` makes `env.versioning` complete in
an editor and makes `env.versionning` a type error.
Reading another resource's output, such as `assets.arn`, returns a lazy `Ref`.
The read creates the dependency edge, so no `depends_on` declarations or string
addresses are needed. The `Ref` resolves to the real value at apply time.
## Three ideas
**Enforced determinism.** Configs are Python, executed by *Atlas-lang*: a
bounded interpreter with no clock, randomness, environment or network. Two runs of
the same config produce a byte-identical intermediate representation and the same
content hash. The interpreter enforces this; it does not rely on author
convention.
**Graph state with Merkle skip.** Resources form a dependency graph, and each node
carries a hash of its own inputs combined with its dependencies' hashes. Apply
skips any node whose hash is unchanged without calling a provider, and reconciles
independent nodes in parallel.
**Per-field mutability.** Every field is declared `mutable()`, `immutable()` or
`computed()`. A mutable change is an in-place update, an immutable change is a
replacement, and computed fields never contribute to a diff. The cost of a change
is visible in the type before anything runs.
## The pipeline
Every `plan` and `apply` runs one pipeline. Everything before the diff is pure and
deterministic; everything after it performs I/O.
```mermaid
flowchart TB
subgraph pure["Deterministic — no I/O; same input, same bytes"]
direction LR
cfg["infra.py"] --> lang["Atlas-lang subset check + fuel-bounded eval"]
lang --> ir["IR + canonical JSON (RFC 8785) stable content hash"]
ir --> dag["Dependency graph Refs become edges, cycles rejected"]
dag --> merkle["Two-phase Merkle input_hash per node"]
end
merkle --> diff{"Diff"}
store[("State backend sqlite · s3+dynamodb · postgres")] -.->|prior hashes| diff
diff --> plan["Plan ordered, policy-checked"]
plan -->|plan| changeset["Changeset NOOP · CREATE · UPDATE · REPLACE · DELETE"]
plan -->|apply| exec["Executor parallel, under a renewed lease"]
exec -->|unchanged hash| skip["Skipped no provider call"]
exec <-->|create · read · update · delete| prov["Providers aws · local · random · yours"]
exec -->|fenced writes| store
```
Because the first half performs no I/O, `plan` needs no credentials, `validate`
can run in a pre-commit hook, and one compiled artifact can be promoted from
staging to production without re-executing the config.
## Where to start
- **[Installation](https://atlantide-org.github.io/install.md)**
---
The standalone binary or the PyPI package, and how to choose.
- **[Coming from Terraform](https://atlantide-org.github.io/comparison.md)**
---
The vocabulary mapping, key differences, and current limitations.
- **[How it works](https://atlantide-org.github.io/how-it-works/index.md)**
---
The mental model, then a hands-on walkthrough that needs no AWS account.
- **[Building on AWS](https://atlantide-org.github.io/aws/index.md)**
---
A multi-environment stack one resource at a time: VPC, S3, SQS, IAM,
Lambda, secrets and policies.
- **[CLI](https://atlantide-org.github.io/reference/cli.md)**
---
Every command, targeting, `--env`, exit codes.
- **[For agents](https://atlantide-org.github.io/agents.md)**
---
`llms.txt`, Markdown for every page, and a skill for coding agents.
**Reference:**
[Authoring](https://atlantide-org.github.io/reference/authoring.md) ·
[Configuration](https://atlantide-org.github.io/reference/configuration.md) ·
[Policies](https://atlantide-org.github.io/reference/policies.md) ·
[Remote state](https://atlantide-org.github.io/reference/remote-state.md) ·
[Providers](https://atlantide-org.github.io/reference/providers.md) ·
[Components](https://atlantide-org.github.io/reference/components.md) ·
[CI and automation](https://atlantide-org.github.io/reference/ci.md) ·
[Troubleshooting](https://atlantide-org.github.io/reference/troubleshooting.md) ·
[Glossary](https://atlantide-org.github.io/glossary.md)
Source: [github.com/atlantide-org/atlantide](https://github.com/atlantide-org/atlantide)
# Installation
Atlantide is distributed as a standalone binary that bundles its own Python
runtime, and as a package on PyPI.
| | Standalone binary | PyPI package |
| --- | --- | --- |
| Python needed | no | 3.12 or newer |
| Providers available | the three built-in ones only | built-in **and** third-party plugins |
| Use it for | CI images, machines without Python, quick trials | anything using a provider plugin, or embedding the engine |
See [provider plugins](#provider-plugins-and-the-binary) for the provider
difference.
---
## Standalone binary
Each release publishes binaries for three platforms on the
[releases page](https://github.com/atlantide-org/atlantide/releases). Each is a
single executable of about 30 MB with no external dependencies.
**Linux (x86-64)**
```bash
curl -L -o atlantide \
https://github.com/atlantide-org/atlantide/releases/latest/download/atlantide-linux-x86_64
chmod +x atlantide
sudo mv atlantide /usr/local/bin/
```
The binary is built on the current Ubuntu runner image and links against its
glibc. On older distributions, install from PyPI.
**macOS (Apple silicon)**
```bash
curl -L -o atlantide \
https://github.com/atlantide-org/atlantide/releases/latest/download/atlantide-macos-arm64
chmod +x atlantide
xattr -d com.apple.quarantine atlantide
sudo mv atlantide /usr/local/bin/
```
> **macOS blocks the binary until the quarantine flag is cleared**
>
> The published binaries are not code-signed or notarised. macOS adds a
> quarantine attribute to downloaded files, and Gatekeeper refuses to run an
> unsigned executable that carries it, reporting that the developer cannot be
> verified.
>
> The `xattr -d com.apple.quarantine` line above removes the attribute.
> Alternatively, open the file once from Finder with right-click → Open and
> confirm the prompt.
**Windows (x86-64)**
```powershell
curl.exe -L -o atlantide.exe `
https://github.com/atlantide-org/atlantide/releases/latest/download/atlantide-windows-x86_64.exe
```
Move `atlantide.exe` to a directory on your `PATH`.
### Confirm it runs
```bash
atlantide --version
```
```
atlantide 0.x.y
```
### Provider plugins and the binary
Providers are discovered through the `atlantide.providers` entry-point group, so
they must be installed alongside atlantide as Python distributions. The standalone
binary has no site-packages, advertises no entry points, and uses only the
providers compiled into it:
```console
$ atlantide providers
3 provider(s)
┏━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ name ┃ types ┃ module ┃ summary ┃
┡━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ aws │ 25 │ atlantide.providers.aws │ AWS resources over boto3. │
│ local │ 3 │ atlantide.providers.local │ Files and no-ops on the local │
│ │ │ │ machine; needs no credentials. │
│ random │ 4 │ atlantide.providers.random │ Values generated once at apply │
│ │ │ │ and pinned in state. │
└────────┴───────┴────────────────────────────┴────────────────────────────────┘
```
If a config imports a third-party provider, install from PyPI. With the binary,
the failure appears as config being unable to find the provider's types, not as a
missing plugin.
---
## From PyPI
Requires Python 3.12 or newer.
```bash
uv tool install atlantide # standalone CLI, isolated environment
pipx install atlantide # the same, via pipx
uv add atlantide # into a project
pip install atlantide # into the current environment
```
For the CLI alone, use `uv tool install` or `pipx`; both install atlantide and its
dependencies in an isolated environment.
Use `uv add` or `pip install` when embedding the engine as a library, or when a
config imports a provider plugin. A plugin must resolve in the same environment as
atlantide.
### Extras
The Postgres state backend requires an extra, which installs a database driver:
```bash
pip install 'atlantide[postgres]'
```
The sqlite and S3 backends need nothing beyond the base install.
---
## Building the binary yourself
Build the binary yourself for a platform with no published asset, or to pin it to
a specific commit:
```bash
git clone https://github.com/atlantide-org/atlantide
cd atlantide
make binary # uv run --extra build pyinstaller atlantide.spec --clean --noconfirm
```
The executable is written to `dist/atlantide`. It bundles whatever is installed
in the build environment, so this is also how to produce a binary that includes a
provider plugin or the Postgres driver.
---
## Verifying an installation
```bash
atlantide --version # the version, and that the executable runs at all
atlantide providers # which providers this installation can see
```
`atlantide providers` also lists any plugin that failed to load. Otherwise, a
failed plugin is only visible as config being unable to find its types.
To confirm an end-to-end setup without credentials or an AWS account, follow the
[walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md). It uses the `local` provider and
creates only files on disk.
---
## Upgrading and removing
```bash
uv tool upgrade atlantide && uv tool uninstall atlantide
pipx upgrade atlantide && pipx uninstall atlantide
pip install --upgrade atlantide && pip uninstall atlantide
```
To upgrade a standalone binary, download the new release over the old file and
check it with `atlantide --version`.
Upgrading does not modify state. When a newer atlantide opens a store, it migrates
it forward automatically; an older atlantide refuses to read a newer store.
Upgrade every machine that shares a backend at the same time. See
[Managing state](https://atlantide-org.github.io/reference/remote-state.md#managing-state).
---
## Scaffolding a project
`atlantide init` writes `atlantide.toml`, a starter config and a `.gitignore`,
then compiles the result before reporting success:
```bash
atlantide init # `minimal` template, local state
atlantide init --template aws # an AWS starter instead
atlantide init --state s3 --bucket acme-atlantide-state \
--key prod/atlantide.json --lock-table atlantide-locks
```
| Option | Values |
| --- | --- |
| `--template` | `minimal` or `aws` |
| `--state` | `local`, `s3` or `postgres`; writes the matching `[state]` table |
| `--bucket`, `--key`, `--lock-table` | s3 state settings |
| `--dsn`, `--schema` | postgres state settings |
| `--secrets` | `keyfile`, `env` or `ssm` |
| `--force` | allow overwriting existing files or nesting inside another project; refused otherwise |
The `minimal` template needs no credentials; see the
[quickstart](https://atlantide-org.github.io/index.md#quickstart).
---
## Next steps
- **[How it works](https://atlantide-org.github.io/how-it-works/index.md)** — the mental model, then a
walkthrough that needs no AWS account.
- **[Building on AWS](https://atlantide-org.github.io/aws/index.md)** — a multi-environment stack built one
resource at a time.
- **[CLI](https://atlantide-org.github.io/reference/cli.md)** — every command and flag.
# Coming from Terraform, Pulumi or CDK
Like these tools, Atlantide declares infrastructure, diffs it against recorded
state, and reconciles the difference. It differs in how determinism is enforced
and in how much of the engine is exposed.
## Vocabulary
| You know | Here it is called | Notes |
|---|---|---|
| `terraform plan` / `apply` | `atlantide plan` / `apply` | `plan` needs no credentials. |
| HCL / TypeScript program | Atlas-lang config (`infra.py`) | Real Python, executed by a bounded interpreter. |
| Provider | Provider | Python packages, discovered by entry point. |
| Module / `ComponentResource` / Construct | [Component](https://atlantide-org.github.io/reference/components.md) | Python; config uses them but cannot define them. |
| `terraform init` | [`atlantide component add`](https://atlantide-org.github.io/reference/components.md) | Pins to a commit + content hash. |
| Backend / remote state | [State backend](https://atlantide-org.github.io/reference/remote-state.md) | SQLite, S3+DynamoDB, or Postgres. |
| `terraform import` | [`atlantide import`](https://atlantide-org.github.io/reference/cli.md#adopting-existing-resources) | Refuses to import a resource that differs from config. |
| `terraform refresh` | [`atlantide refresh`](https://atlantide-org.github.io/reference/cli.md#detecting-drift) | Read-only unless `--write`. |
| `depends_on` | `depends_on` | Rarely needed; reading a field creates the edge. |
| `lifecycle { prevent_destroy }` | `Lifecycle(prevent_destroy=True)` | Plus `create_before_destroy`, `ignore_changes`, `aliases`. |
| `moved` block | `Lifecycle(aliases=(...))` | Rename without replace. |
| `terraform output` | `atlantide output` | Sensitive values need `--reveal`. |
| Workspace | [Stack](https://atlantide-org.github.io/reference/authoring.md#stacks) + [`Config` environment](https://atlantide-org.github.io/reference/configuration.md#environments) | Both in-config: one `Config` declares the environment matrix, `--env` selects. |
| `terraform workspace select` | [`--env`](https://atlantide-org.github.io/reference/cli.md#narrowing-a-run) | An unselected environment is out of scope, never planned for deletion. |
| `*.tfvars` | [Inputs](https://atlantide-org.github.io/reference/configuration.md#inputs) + [profiles](https://atlantide-org.github.io/reference/configuration.md#profiles) | Inputs are per-run values; profiles point a run at a state backend/account. |
| Sentinel / OPA | [Policies](https://atlantide-org.github.io/reference/policies.md) | Native Python, evaluated at plan time. |
| Terraform Registry | No central registry | Components are git URLs, pinned by commit. |
## Key differences
**A real language, but a bounded one.** Pulumi and CDK use a full programming
language, so a config's plan can depend on the clock, the environment or a network
call. Terraform avoids this by not being a programming language. Atlantide configs
are Python (typed and `mypy`-checkable) but run under
[Atlas-lang](https://atlantide-org.github.io/reference/authoring.md#what-config-may-not-do), an interpreter with
no clock, randomness, environment, network or filesystem. The interpreter enforces
determinism.
**The diff is a Merkle comparison, not a per-resource read.** Each node's hash
folds in its dependencies' hashes, so an unchanged subgraph is skipped without any
provider call. See [The Merkle hash](https://atlantide-org.github.io/how-it-works/merkle.md).
**`plan` needs no credentials.** Everything up to the diff is pure computation
over the config and the recorded state. `validate` can run in a pre-commit hook,
and `plan` can run on a pull request with read access to state and no provider credentials.
**Per-field mutability is in the type.** Every field is declared `mutable()`,
`immutable()` or `computed()`, so whether a change is an in-place update or a
replacement is known from the type, before any plan runs.
**Build once, deploy anywhere.** `atlantide build` compiles a config into a
content-hashed `.atlas` artifact with provider versions pinned. `deploy` applies
the artifact without re-executing the config, so staging and production receive
identical bytes.
## Limitations
- **Limited provider coverage.** Three built-in providers and about two dozen AWS
resource types, compared with thousands in Terraform. `atlantide resources`
lists what exists. The plugin interface is open; there is no third-party
provider ecosystem.
- **No central registry.** Components are git URLs you pin yourself.
- **No HCL interoperability.** Atlantide cannot read Terraform configuration or
state. Existing resources are adopted one at a time with `atlantide import`.
- **Versioned state format.** The format can change between minor releases. It
migrates forward automatically, and an older atlantide refuses a newer store.
- **Beta.** The package is classified as beta.
## Migration
There is no converter. Migration proceeds in slices:
1. Write the config for a slice of existing infrastructure.
2. `atlantide validate`, then `atlantide plan` — everything will show as
`CREATE`, because state knows nothing yet.
3. `atlantide import` each resource. A resource whose live values differ from
config is refused, not adopted, so this step also checks that the config
matches what is deployed. `--dry-run` previews the import without changing
state.
4. `atlantide plan` again. The slice should now be entirely `NOOP`.
# CLI
```bash
atlantide init # scaffold a project
atlantide plan infra.py # preview changes
atlantide apply infra.py # reconcile (parallel)
atlantide refresh # detect drift vs live state
atlantide graph infra.py --format mermaid # dependency graph
atlantide destroy # tear down
```
## Starting a project
`atlantide init [DIRECTORY]` writes `atlantide.toml`, a starter config and a
`.gitignore`, then compiles the config before reporting success. `--template`
takes `minimal` (default; `local` provider, no credentials) or `aws`.
`--state local|s3|postgres` writes the matching `[state]` table. See
[Installation](https://atlantide-org.github.io/install.md#scaffolding-a-project) for all flags.
## Adopting existing resources
`atlantide import` records an existing resource in state, so the next `plan`
reports it unchanged instead of proposing to create it. It creates, updates and
deletes nothing.
```bash
atlantide import # list what is importable, and how
atlantide import aws.S3Bucket:assets # located by name
atlantide import aws.Vpc:main vpc-0abc123 # located by a provider id
atlantide import aws.Vpc:main vpc-0abc123 --dry-run
```
Declare the resource in config first, then pass its node id. Some types are
located by name; others need a provider-assigned id that config cannot know,
such as a VPC's `vpc_id` or a certificate's `arn`. `import` with no arguments
lists which types need which.
**A resource whose live values differ from config is not imported.** Reconcile
the config, or pass `--allow-drift` to import it anyway; the next plan then
shows the update. `-v` names the fields the provider's read did not cover.
To undo an import, run `atlantide state rm `. It removes the state row and
leaves the resource in place.
## Detecting drift
`atlantide refresh` reads live provider state and reports how it differs from
recorded state. It is read-only unless `--write` is given, which syncs drifted
outputs back to state.
A resource the provider cannot find is reported but **kept** in state unless
`--prune` is also given. Removing it on a single failed read would cause the
next apply to create a second copy.
Drift is detected only in fields the provider's `read` reports. The report
states how many of each resource's inputs were covered; `-v` names the rest.
## Common options
State defaults to `atlantide.db`. Override it with `--state`.
The mutating commands (`apply`, `deploy`, `destroy`, `refresh`) accept
`--confirm/-y`, `--region` and `--parallelism/-p`. Commands that write state
also accept `--on-failure rollback|halt`. The default, `rollback`, undoes the
nodes a failed run already completed.
## Narrowing a run
`--target/-t` accepts a full node id, a short form, or a glob:
```bash
atlantide apply -t aws.S3Bucket:assets # and its dependencies
atlantide destroy -t aws.Vpc:main # and its dependents
atlantide apply --replace aws.LambdaFunction:worker
```
`apply` includes the target's dependencies; `destroy` includes its dependents.
A pattern that matches nothing is an error, not an empty run. `--replace` still
honours `prevent_destroy`.
`--env/-e` narrows a run to one environment of the config's
[`Config`](https://atlantide-org.github.io/reference/configuration.md#environments). It is repeatable:
```bash
atlantide plan --env prod # prod only
atlantide apply --env dev --env staging
```
An unselected environment is **out of scope, not undeclared**: its recorded
state is not diffed and is never planned for deletion. The plan lists the
environments it excluded:
```
envs: prod (of dev, prod) — dev is not planned and will not change
```
Naming an environment the config does not declare is an error. Stacks declared
outside the `config.envs()` loop, such as a shared `common`, are not part of the
matrix and stay in the graph.
`plan`, `apply`, `validate` and `build` accept `--env`. `build` records the
selection in the artifact, so a later `deploy` covers the same environments.
`destroy` also accepts it but selects by stack, because it reads state only, not
config, and an environment's stack is its name.
`--target` and `--env` can be combined. A target that matches only excluded
environments is an error.
## Interrupting a run
Ctrl-C cancels the run and follows the failure path. Under the default
`--on-failure rollback`, resources already created are undone, then the process
exits with code 130.
A second Ctrl-C abandons the cleanup. State may then not match what exists; run
`atlantide refresh` before applying again.
The second press does **not** release the run's lock, because the abandoned run
may still have provider calls in flight. The lease expires on its own, or can be
cleared with `atlantide state unlock`.
## Output and diagnostics
`--log-level debug|info` traces a run to stderr; `--log-format json` makes the
stream machine-readable. `--audit-log FILE` appends every run's events to a
JSONL file. These are global options and go before the command:
```bash
atlantide --log-level debug --log-format json plan
atlantide --debug apply # full traceback on error
```
`--json` is a per-command option that replaces the rendered output on stdout.
See [CI and automation](https://atlantide-org.github.io/reference/ci.md#machine-readable-output).
Every command that touches state first prints which state it uses:
```
state: s3://acme-atlantide-state/prod/atlantide.json
```
Under `--json` the same value appears as the payload's `state` field.
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Success. With `--detailed-exitcode`: nothing to do — no pending changes, no drift. |
| `1` | An error, **or** a mandatory policy denied the plan, **or** (`providers`) a plugin failed to load. |
| `2` | Only with `--detailed-exitcode`: `plan` has changes pending, or `refresh` found drift. |
| `130` | The run was interrupted (Ctrl-C). |
Without `--detailed-exitcode`, `plan` exits `0` whether or not changes are
pending. Use it to gate CI:
```bash
atlantide plan --detailed-exitcode || case $? in
2) echo "changes pending" ;;
*) exit 1 ;;
esac
```
A denied plan exits `1` with or without `--detailed-exitcode`.
## Other commands
**`build` / `verify` / `deploy`**: produce, check and apply portable `.atlas`
artifacts. An artifact is content-hashed with provider versions pinned, so a
config compiled once can be promoted between environments as identical bytes.
**`validate`**: check that a config compiles, stays within the Atlas-lang
subset and has an acyclic graph. It touches no state and calls no provider, so
it needs no credentials. Use it in pre-commit hooks and pull-request checks,
where `plan` would need access to a state backend.
**`output`**: print the values a previous apply exported. It reads only state,
so it works while the config is being edited:
```bash
vpc=$(atlantide output vpc_id)
```
Sensitive values require `--reveal`.
**`state`**: inspect and administer the state store. See
[Remote state](https://atlantide-org.github.io/reference/remote-state.md).
**`secret`**: manage the local AES-GCM encrypted name-to-value store:
```bash
atlantide secret set app/signing-key # value prompted (hidden) if omitted
atlantide secret get app/signing-key -r # print plaintext (--reveal required)
atlantide secret list # names only, no values
atlantide secret rm app/signing-key
```
**`component`**: `add`, `lock`, `vendor` and `verify` for published components.
**`providers`**: list the provider plugins this installation can see, including
any that failed to load. Without this, a failed plugin shows up only as config
being unable to find its types.
**`resources` / `schema`**: list the available resource types, and show one
type's fields and their mutability.
# Configuration
Atlantide has three kinds of configuration:
| | What it is | Where it lives | Selected by |
|---|---|---|---|
| [Environments](#environments) | The typed matrix of environments the system has, and what differs between them | `Config(...)` in the config file | `--env` |
| [Inputs](#inputs) | A per-run value from outside the repository | `-var`, `--var-file`, `[inputs]` | supplied per run |
| [Profiles](#profiles) | Where a run points: state backend, AWS profile, parallelism | `[profile.]` in `atlantide.toml` | `--profile/-P` |
`--profile` and `--env` are independent; neither implies the other.
## The project file
`atlantide.toml` sets the config path, the state database and other defaults,
so commands inside the project need no flags.
The file is found by searching the working directory and then each parent, as
git locates a repository root. Relative paths in it resolve against the
directory containing the file, not the directory the command runs from.
```toml
config = "infra.py"
state = "atlantide.db"
parallelism = 8
aws_region = "eu-north-1"
aws_profile = "default"
aws_endpoint = "http://localhost:4566" # send every AWS call here instead
secrets_key = ".atlantide.key"
secrets_store = ".atlantide.secrets"
[aws.aliases.prod] # alternate account (multi-account)
profile = "prod-account" # a resource selects it via provider_alias="prod"
```
## Environments
A `Config` declares every environment and what differs between them, in one
place:
```python
from atlantide.core import Config, EnvSchema, Stack
class AppEnv(EnvSchema):
domain: str
size: int = 1
config = Config(
AppEnv,
envs={
"dev": {"region": "eu-north-1", "domain": "dev.example.io"},
"prod": {"region": "us-east-1", "domain": "example.io", "size": 5},
},
)
for env in config.envs(): # env: AppEnv
with Stack(env.name, config=env):
...
```
Each variable declares a type and an optional default. All values are checked
when the `Config` is constructed, so a missing or mistyped prod value fails
`atlantide validate` in CI rather than the prod apply.
An environment name becomes the stack name, so it must be an identifier.
### The schema
`EnvSchema` is the only class Atlas-lang accepts: a module-level class whose body
contains annotated fields only, with no methods, decorators or metaclass.
Because the variables are ordinary attributes, an editor completes
`env.domain`, and `env.domian` is a type error before anything runs.
A field may be annotated `str`, `int`, `float`, `bool`, `list` or `dict`, or
`X | None` to make it optional and nullable. Parameterized generics such as
`list[str]` are not supported.
Alternatively, the schema can be a mapping of `var()` declarations, which
provides no static type information:
```python
from atlantide.core import Config, var
config = Config(schema={"size": var(int, default=1)}, envs=)
```
Both forms run the same validation. A `var()` without a default is required:
every environment must supply it, and a missing value is reported when the
`Config` is built, not where the value is read. `default=None` makes it
optional.
### Well-known keys
`region`, `tags` and `name_prefix` are implicit in every schema, so
`Stack(env.name, config=env)` needs no separate `region=`:
```python
with Stack(env.name, config=env): # region/tags/name_prefix from the env
...
```
An explicit `Stack` argument takes precedence over the environment, except
`tags`, which merge (the stack's own taking precedence). A schema may
re-declare a well-known key to make it required or to narrow its type.
Inside the body the environment is ambient: a [component](https://atlantide-org.github.io/reference/components.md) reads
it with `current_config()` instead of receiving it through a constructor.
### Selecting one
```bash
atlantide plan infra.py --env prod # prod only
atlantide apply infra.py --env dev --env prod # repeatable
```
An unselected environment is **out of scope, not undeclared**: its state is
never diffed or planned for deletion, and the plan lists what it excluded.
```
envs: prod (of dev, prod) — dev is not planned and will not change
```
Naming an environment the config does not declare is an error. Stacks declared
*outside* the `config.envs()` loop, such as a shared `common`, are not part of
the matrix and are unaffected by `--env`, so a shared VPC stays in the graph
when a run is narrowed.
`--env` is accepted by `plan`, `apply`, `validate`, `build` and `destroy`. On
`destroy` it selects by stack name, because destroy reads state only, not
config, and an environment's stack is its name.
`build` records the selection in the `.atlas` artifact, so `deploy` covers
the same environments as the build. Determinism is over *(config, inputs,
selected environments)*.
## Inputs
An input is a per-run value supplied from outside the repository, such as a CI
build number or a fork's name prefix. Inputs arrive as text:
```python
env = atlantide.input("env", "dev") # with a default
count = int(atlantide.input("count")) # values arrive as strings from -var
```
```bash
atlantide plan -var env=prod
atlantide plan --var-file prod.toml
```
Inputs can be set in three places, in increasing order of precedence:
`[inputs]` in `atlantide.toml` (or `[profile..inputs]` for one profile),
`--var-file`, and `-var` on the command line.
Every plan prints the inputs it read. Only inputs the config reads affect the
result.
> **An input is not an environment**
>
> Values that differ *between environments* belong in a
> [`Config`](#environments), which is checked in, typed and validated at
> construction. Values that differ *between runs of one environment* are
> inputs. An environment's value may be built from an input.
> **Inputs are not for secrets**
>
> `atlantide.secret("name")` returns a handle, not a value. A secret passed as
> an input is written in plaintext to the IR, build artifacts and state.
## Profiles
A `[profile.]` table overlays the top level and is selected with
`--profile/-P` or the `ATLANTIDE_PROFILE` environment variable. A profile sets
where a run points: state backend, AWS account and parallelism.
The overlay is applied per table, so a profile sets only the keys it changes and
inherits the rest:
```toml
parallelism = 4
[profile.prod]
parallelism = 16
[profile.prod.state] # inherits the base [state] keys it omits
backend = "s3"
bucket = "acme-atlantide-state"
key = "prod/atlantide.json"
lock_table = "atlantide-locks"
```
Naming a profile the file does not define is an error; there is no fallback to
the defaults.
# Authoring
Reference for config constructs and their effects. For step-by-step
introductions, see the [walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md) and
[Building on AWS](https://atlantide-org.github.io/aws/index.md).
## Declaring a resource
```python
Resource("logical-name", **fields, lifecycle=..., depends_on=[...])
```
The logical name is positional and must be an identifier. Every resource
accepts `lifecycle=` and `depends_on=`; all other arguments are the type's own
fields, whose mutability determines the effect of changing them. Run
`atlantide schema ` to list them.
Reading another resource's computed field returns a lazy `Ref`, not a value.
The read creates the dependency edge, with no string addresses, and resolves at
apply.
## Stacks
```python
with Stack("prod", region="eu-north-1", tags={"env": "prod"}, name_prefix="acme"):
...
```
A stack scopes everything declared in its body. Resources inside get the node id
`{stack}:{type}:{name}`.
- **`region`** is required. It is the default for every resource in the body
that has a `region` field and does not set one.
- **`tags`** are merged into every resource in the body that has a `tags` field.
Nested stacks merge, with the inner stack taking precedence; a resource's own
tags take precedence over the stack's.
- **`name_prefix`** composes the cloud name of resources whose name field is
marked `physical_name` as `{name_prefix}-{base}-{stack}`. If omitted, the
enclosing stack's value is used.
- **`config`** is one environment of a [`Config`](https://atlantide-org.github.io/reference/configuration.md#environments).
Its `region`, `tags` and `name_prefix` keys fill in the matching arguments, and
the environment is ambient in the body. An explicit argument takes precedence
over the environment; `tags` merge.
Stacks nest, and a config may declare any number of them. The common pattern is
a loop over the environments of a `Config`:
```python
for env in config.envs():
with Stack(env.name, config=env):
S3Bucket("assets", versioning=env.versioning)
```
Per-environment differences are then typed, declared variables rather than
branches on the stack name. See [Environments](https://atlantide-org.github.io/reference/configuration.md#environments).
### Per-block region
`with region("us-east-1"):` overrides the enclosing stack's region for the
resources declared inside it, and nothing else. A typical use is a resource a
service requires in a specific region, such as an ACM certificate for
CloudFront.
## Outputs
```python
output("assets_arn", assets.arn)
```
`output()` exports a literal or a `Ref` under a name, namespaced by the
enclosing stack: inside `with Stack("prod")` it becomes `prod:assets_arn`;
outside any stack it goes in `default`. Outputs are persisted to state after
apply and read with `atlantide output`.
`output()` also returns a handle. A later stack in the same config can use it
to consume the value; the reference becomes a dependency edge rather than a
state lookup.
A stack applied by a **separate** config must be referenced explicitly:
```python
vpc_id = StackReference("network").output("vpc_id")
```
`StackReference` resolves from the referenced stack's committed outputs at
apply, so that stack must already be applied to the same state store.
## Lifecycle
```python
S3Bucket("assets", lifecycle=Lifecycle(prevent_destroy=True), ...)
```
| Field | Effect |
|---|---|
| `prevent_destroy` | A planned `DELETE` — or the destroy half of a `REPLACE` — on this resource fails the whole plan. |
| `create_before_destroy` | A `REPLACE` creates the replacement before destroying the old one. Falls back to destroy-first when the two would collide on identity. |
| `ignore_changes` | Field names whose drift is ignored: excluded from the Merkle `input_hash` and from the diff's changed-field set, so a change to one never triggers `UPDATE` or `REPLACE`. |
| `aliases` | Prior node ids (or bare old logical names) this resource was **renamed from**. |
### Rename without replace
To rename a resource without recreating it, list its previous name as an alias.
The engine maps it onto the existing state entry:
```python
S3Bucket("assets_v2", lifecycle=Lifecycle(aliases=("assets",)), ...)
```
When the new id is absent from state and an alias id is present, the plan
rewrites the existing row to the new id.
To guard a whole stack rather than one resource, see
[`deny-destroy-in-protected`](https://atlantide-org.github.io/reference/policies.md#deny-destroy-in-protected).
## Explicit ordering
`depends_on=[other]` orders one resource after another without reading a value
from it. Use it for dependencies that carry no value, such as an IAM policy that
must propagate before a dependent service starts, or a bucket policy that must
exist before an upload.
Reading a field such as `other.arn` already creates the edge; `depends_on` is
needed only when nothing is read. Pass the resources or their node ids.
The edge is excluded from the content hash, so adding one changes ordering only
and never causes a resource to be re-planned.
## Output combinators
`concat`, `interpolate` and `join` build strings from values that are not yet
known. A computed field such as `assets.arn` is a `Ref`, so ordinary string
formatting does not apply to it.
```python
S3BucketPolicy("p", bucket=assets.bucket, statements=[
allow("s3:GetObject", on=concat(assets.arn, "/*"), principal="*"),
])
```
> **Never put a `Ref` in an f-string**
>
> `"arn:" + assets.arn` fails at evaluation, but `f"arn:{assets.arn}"` does
> not: it validates, plans cleanly, and writes the literal text
> `Ref(node_id=..., attr=...)` into the field. Use `interpolate("arn:{}",
> assets.arn)` instead.
Combinators serialize as data, not closures. The config is not re-executed at
apply; the engine evaluates the recorded operation once the referenced values
resolve.
## Derived values
Atlas-lang provides a set of deterministic pure functions in the global
namespace for values computed *at config-evaluation time* from known inputs.
| Function | Purpose |
|---|---|
| `slugify(s)` | ASCII-fold, lowercase, non-alphanumerics to a single `-`. `"Café Menu!"` → `"cafe-menu"`. |
| `uuid5(namespace, name)` | Name-based UUID, stable across machines and runs. |
| `sha256_hex(s)` | Hex SHA-256 of a string. |
| `hmac_sha256_hex(key, msg)` | Hex HMAC-SHA256. |
| `b64encode(s)` / `b64decode(s)` | Base64, over UTF-8 text. |
| `to_json(v)` | Canonical JSON: keys sorted, no insignificant whitespace, byte-stable — safe to hash or embed in a policy document. |
| `from_json(s)` | Parse JSON into plain data. |
| `merge(a, b, ...)` | Deep-merge dicts left to right, later winning; nested dicts merge recursively, other types are replaced. Arguments are not mutated. |
These functions operate on values, not `Ref`s. For values known only at apply,
use the [combinators](#output-combinators).
## Environments
```python
class AppEnv(EnvSchema): # annotated fields only
domain: str
size: int = 1
config = Config(AppEnv, envs={
"dev": {"region": "eu-north-1", "domain": "dev.example.io"},
"prod": {"region": "us-east-1", "domain": "example.io", "size": 5},
})
```
A `Config` declares every environment and its values, and is validated at
construction, so a missing or mistyped prod value fails `validate` rather than
the prod apply. `region`, `tags` and `name_prefix` are implicit in every schema,
so `Stack(env.name, config=env)` needs no `region=`.
`atlantide plan --env prod` narrows a run to one environment. The others are out
of scope, not undeclared, so their state is never planned for deletion. For
details, including the `var()` form, see
[Configuration](https://atlantide-org.github.io/reference/configuration.md#environments).
## Inputs and secrets
```python
build = atlantide.input("build", "local") # a per-run value, with a default
key = atlantide.secret("signing-key") # a handle to a stored secret
```
`atlantide.input()` reads a value supplied by `-var`, `--var-file` or
`[inputs]`. If an input has no default and is not supplied, the error names
all three sources. Determinism holds over *(config, inputs, selected
environments)*, and only inputs the config reads affect the result. See
[Configuration](https://atlantide-org.github.io/reference/configuration.md#inputs).
Values that differ between environments belong in a `Config`, which is checked
in and typed. Values that differ between runs of one environment are inputs.
`atlantide.secret()` returns a `SecretRef`, which is a name. Only the name
reaches the IR, the artifact and state; the plaintext is resolved in memory from
the secrets store at apply and redacted in plan output and logs. It does not
read from inputs, because a secret passed as an input would be written in
plaintext to all three.
Set the value with `atlantide secret set `, and enforce secret handles
with [`require-secret-refs`](https://atlantide-org.github.io/reference/policies.md#require-secret-refs).
## Multi-account
AWS resources accept `provider_alias=`, which names an entry under
`[aws.aliases.*]` in `atlantide.toml`, to target another account:
```python
S3Bucket("assets", provider_alias="prod", ...)
```
The field is `immutable()`, so moving a resource between accounts is a replace,
not an update.
## What config may not do
Config is executed by Atlas-lang, not CPython. It has no clock, randomness,
environment, network or filesystem access.
Constructs outside the subset are rejected before evaluation, with the reason
and the alternative:
| Rejected | Because |
|---|---|
| `while` | Halting must be provable; use a bounded `for`. |
| `class` | Define resource types in a provider, not in config. One exception: a module-level [`EnvSchema`](https://atlantide-org.github.io/reference/configuration.md#the-schema) whose body is annotated fields only — no methods, decorators, metaclass or type parameters — which is a declaration, not a computation. |
| `try` / `raise` | No exceptions in config; guard with `if`. |
| `async` / `await` | Config is synchronous and pure. |
| `yield` | No generators; build lists with comprehensions. |
| `global` / `nonlocal` | No mutable module or closure state; pass arguments. |
| `:=` | Use a separate assignment. |
| `del` | Bound values are immutable. |
Imports are restricted to `atlantide.*`, so a vendored
[component](https://atlantide-org.github.io/reference/components.md) can be imported and `import os` cannot.
Bounded `for` loops, comprehensions, functions and conditionals are available.
Recursion is not rejected, but evaluation runs on a fuel budget, so a runaway
computation stops with `FuelExhaustedError` instead of hanging.
In summary: per-run values come from `atlantide.input()`, per-environment
values are declared in a [`Config`](https://atlantide-org.github.io/reference/configuration.md#environments), and code
that needs a real `class`, such as a [component](https://atlantide-org.github.io/reference/components.md) or a provider,
is ordinary Python outside the config.
# Policies
A policy inspects one node's *pending change* and passes or fails it. Policies
run at plan time over the changeset, so a violation appears in the plan, before
any apply starts.
```python
from atlantide.policy import enforce
enforce("require-tags", keys=["env", "owner"])
enforce("deny-destroy-in-protected", stacks=["prod"])
enforce("require-secret-refs")
```
Call `enforce()` during config evaluation, conventionally at the top of the
file, outside any `Stack` block. Bindings are recorded in the IR and
round-trip through a `.atlas` artifact, so a plan built from an artifact
enforces the same policies as the config that produced it.
## Levels
```python
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 the named resource types. A single string is one
type name, not a sequence of characters:
```python
enforce("require-tags", types="aws.S3Bucket", keys=["owner"])
enforce("require-tags", types=["aws.S3Bucket", "aws.SqsQueue"], keys=["owner"])
```
A policy bound more than once is evaluated separately with each binding's
arguments, for example one `deny-destroy-in-protected` binding per environment.
## What a policy sees
Only **actionable** changes are evaluated; `NOOP` nodes are not passed to
policies. For each change 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`, so the built-in policies that inspect
the resource pass such nodes.
## Built-in policies
### `require-tags`
Requires the tag keys listed in `keys`. With no `keys`, requires only that the
resource has at least one tag.
```python
enforce("require-tags") # any tags
enforce("require-tags", keys=["env", "owner"]) # these keys
```
A key with an empty value counts as missing. Resource types without a `tags`
field are skipped.
### `require-secret-refs`
Every field marked sensitive must hold a `SecretRef` handle, not a literal.
Takes no arguments.
A secret referenced by name stays out of the config, the IR, state and the
`.atlas` artifact; a literal value is written to all four. A field declared with
`secret()` is typed `SecretRef | None`, so pydantic already rejects a literal.
This policy covers fields declared `mutable(..., sensitive=True)` on a plain
`str`, which accept plaintext without error.
### `deny-destroy-in-protected`
Denies destructive actions (`DELETE`, and the destroy half of a `REPLACE`) in
the stacks listed in `stacks`.
```python
enforce("deny-destroy-in-protected", stacks=["prod", "shared"])
```
With no `stacks`, it passes everything; there is no default protected stack.
`deny-destroy-in-prod` is an alias.
> **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 with an argument the policy cannot use fails the plan with a
`PolicyConfigError` naming the policy and the parameter:
```
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 that every instance
carries it:
```python
from atlantide.policy import policy
@policy("require-tags")
class Gadget(AcmeResource):
...
```
The binding applies to that type only and is evaluated alongside the config's
`enforce()` bindings. Config cannot use the decorator, because Atlas-lang
accepts no classes other than an [`EnvSchema`](https://atlantide-org.github.io/reference/configuration.md#the-schema) of
annotated fields. It is an API for Python code: providers and the libraries
that publish [components](https://atlantide-org.github.io/reference/components.md).
## 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 narrowed
changeset that would run. See
[How it works](https://atlantide-org.github.io/how-it-works/index.md#the-whole-pipeline-on-one-page).
# Remote state
State defaults to a local SQLite file, usable from one machine only. Set `[state]`
to a shared backend to use the same commands from a team or a CI runner.
```toml
[state]
backend = "s3" # "local" (default) | "s3" | "postgres"
bucket = "acme-atlantide-state"
key = "prod/atlantide.json"
lock_table = "atlantide-locks" # DynamoDB table, hash key `node_id` (S)
region = "eu-north-1"
kms_key_id = "alias/atlantide" # optional; SSE-S3 (AES256) otherwise
lock_ttl = 300 # seconds a lease lasts before it lapses
lock_renew_interval = 100 # how often a live run pushes that out
```
```toml
[state]
backend = "postgres" # needs the extra: pip install atlantide[postgres]
dsn = "postgresql://…" # or the ATLANTIDE_STATE_DSN env var
schema = "atlantide"
```
## Concurrency
Locks are held per node id, not over the whole store. Apply re-diffs after
acquiring the lease, and actions can appear or disappear between the two diffs,
so the lease covers the whole desired graph plus everything already recorded in
state.
**Two applies run concurrently only if both their configs and their prior state
are disjoint.** Two applies over the same stack serialize.
A run that loses its lease (the hold lapsed and another run claimed it) stops and
reports it. It does not roll back: a compensation is a state write, and the lease
no longer authorises one. Run `atlantide refresh` to recover.
### Preparing the backend
Atlantide does not create the bucket or the lock table. They are the trust root
for shared state and must exist before first use, configured with:
- **Bucket versioning**, to recover from a bad state write.
- **A DynamoDB TTL on `expires_at`**, so abandoned leases expire.
`state check` verifies this setup in one pass. It also probes whether the
endpoint honours conditional writes; an S3-compatible store that accepts the
request but ignores the condition defeats every compare-and-swap the backend
relies on.
```bash
atlantide state check # --no-probe skips the scratch write
```
## Managing state
```bash
atlantide state list # every recorded resource
atlantide state show # one resource's stored inputs and outputs
atlantide state rm # forget a row without destroying anything
atlantide state backup # ./atlantide-state--.atlas-state
atlantide state restore snap.atlas-state
atlantide state migrate --from atlantide.db # local -> remote
atlantide state migrate --to-local atlantide.db # remote -> local
atlantide state unlock # who holds what
atlantide state unlock --owner ci-runner-7 -y # break a dead run's holds
```
**`backup` and `restore`** run under the state lock, so a snapshot cannot capture
part of a concurrent apply. `restore` previews which nodes it will stop tracking,
and refuses a snapshot if state has been written since it was taken, unless
given `--force`. It rewrites only atlantide's record, not cloud resources, so
restoring an old snapshot creates drift.
**`state rm`** removes a row that no longer describes a real resource. It does
not call the provider. If the resource still exists, it becomes untracked and the
next apply creates a second one. The command snapshots state first and reports
the snapshot.
**`state migrate`** copies in a single write in either direction, so an
interrupted migration cannot leave a partially populated destination.
> **Upgrading a shared backend**
>
> The schema is versioned and migrates forward automatically. Take a snapshot
> first, and upgrade every machine sharing the backend at the same time. An
> older atlantide refuses a newer store rather than misreading it.
## Secrets
Secret *values* can come from a remote store. Config, the IR and state hold only
the secret's name:
```toml
[secrets]
provider = "ssm" # "keyfile" (default) | "env" | "ssm"
prefix = "/atlantide/prod/" # secret `db_password` -> /atlantide/prod/db_password
region = "eu-north-1"
```
> **Point `secrets_key` at a shared path when state is shared**
>
> The keyfile encrypts the local value store, salts the rotation digests, and
> seals sensitive outputs at rest.
>
> A teammate without the keyfile cannot unseal those outputs and sees every
> secret field as rotated. `plan` detects this pattern (every secret in state
> mismatching, none intact) and warns instead of listing spurious updates.
> KMS-backed keys are not supported.
# Providers
Atlantide ships with three providers.
**`local`**: `File`, `SourceFile` and `Null`. Requires no credentials and runs
anywhere, including CI.
**`random`**: `Uuid`, `Password`, `Id` and `Timestamp`. Each value is generated
once at apply and pinned in state; re-applying does not regenerate it.
**`aws`**: the resource types below, the `AwsCallerIdentity` and
`AwsAvailabilityZones` data sources, the IAM policy helpers (`allow`, `deny`,
`assume_role`, `ServicePrincipal`), and the `SecureBucket` component. Any resource
can set `provider_alias=` to target an alternate account.
| Area | Types |
| --- | --- |
| Storage | `S3Bucket`, `S3BucketPolicy`, `S3Folder` |
| Messaging | `SqsQueue`, `SnsTopic`, `SnsSubscription` |
| Compute | `LambdaFunction` |
| Identity | `IamRole`, `IamPolicy` |
| Data | `DynamoDbTable` |
| Observability | `CloudWatchLogGroup` |
| Networking | `Vpc`, `Subnet`, `SecurityGroup`, `InternetGateway`, `ElasticIp`, `NatGateway`, `RouteTable` |
| Edge and DNS | `CloudFrontDistribution`, `OriginAccessControl`, `AcmCertificate`, `Route53HostedZone`, `Route53Record` |
## Defaults
**`S3Bucket` blocks public access and enables SSE-S3 encryption by default.**
Set `force_destroy=True` to empty the bucket (objects, versions and delete
markers) before removing it. Without it, S3 refuses to delete a non-empty bucket
and teardown stalls.
**`SecurityGroup` egress defaults to allow-all**, matching the AWS default. Pass
`egress=[]` to revoke it.
**`Subnet` requires an `availability_zone`.** Take it from
`AwsAvailabilityZones` instead of a literal; available zone letters differ
between accounts.
**`CloudFrontDistribution` accepts `aliases` and `certificate_arn`**, and
`Route53Record` accepts an `alias` target. Together they allow serving an apex
domain from CloudFront.
**`LambdaFunction` requires a package.** Set `code_path=` to a local zip or
directory, or set `s3_bucket=` and `s3_key=` for an uploaded package. A directory
is zipped with sorted entries and fixed timestamps, then fingerprinted into
`code_sha256` at config-evaluation time. Two checkouts of the same source produce
the same digest; editing the source produces a planned update.
## Data sources
A data source reads a fact about the account instead of hardcoding it, so one
config can run against multiple accounts:
```python
from atlantide.providers.aws import AwsAvailabilityZones, AwsCallerIdentity
me = AwsCallerIdentity("me")
zones = AwsAvailabilityZones("azs")
```
Each is read once at apply and pinned in state. A plan makes no provider call,
and two runs of the same config produce identical IR.
Data sources are never destroyed. `destroy` drops the state entry without
calling the provider, because atlantide did not create the object being read.
## Writing a provider
A provider is a Python package that declares one entry point:
```toml
[project.entry-points."atlantide.providers"]
acme = "acme_atlantide:PLUGIN"
```
```python
from atlantide.core.plugin import ProviderPlugin
PLUGIN = ProviderPlugin(
name="acme",
types={Gadget.type_name(): Gadget},
factory=lambda settings: AcmeProvider(**settings),
module="acme_atlantide", # what config is allowed to import
)
```
Once installed, `atlantide providers` lists it and config can use
`from acme_atlantide import Gadget`.
`factory` receives the provider's `[provider.]` settings as a raw mapping,
so a third-party provider can accept options atlantide does not define.
A plugin that fails to load is reported and does not block unrelated commands.
`--no-plugins` ignores all installed plugins, for reproducing a build or
bisecting a plugin that breaks a run.
> **A plugin is trusted code**
>
> A plugin runs in the same process, with the same access as atlantide.
> Atlas-lang sandboxes a malicious *config*, not a malicious *plugin*. Treat
> installing a plugin like installing any other dependency.
# Components
A component groups several resources behind one parameterized object, like
Pulumi's `ComponentResource` or a CDK Construct. It turns a recurring piece of
architecture (a hardened bucket, a queue with its dead-letter queue, a service
and its role) into a single call.
Config can use components but cannot define them. Atlas-lang admits no `class`
with a body, except an [`EnvSchema`](https://atlantide-org.github.io/reference/configuration.md#the-schema) of annotated
fields, so a component is ordinary Python written by a library or provider
author:
```python
from atlantide.core import Component, child
from atlantide.providers.aws import S3Bucket
class SecureBucket(Component):
def __init__(self, name, *, bucket):
self.bucket = child(S3Bucket, "assets", bucket=bucket)
# ...plus a TLS-only hardening policy wired to self.bucket
```
A component has no node of its own in the graph. Its children become ordinary
flat resources named `{component}-{child}`, so two instances of the same
component never collide.
## Publishing and consuming
Components are fetched once and pinned, as with `terraform init`. Config cannot
import from a live URL: it runs in a sandbox that may import only `atlantide.*`
and has no network access.
`component add` clones the source, records the resolved commit and a content
hash, and writes the tree into `.atlantis/`. Vendored code is imported under the
`atlantide.components.*` namespace, which the sandbox permits.
```bash
atlantide component add https://github.com/acme/secure-bucket --ref v1.2.0 --as acme --subdir src
atlantide component verify # re-hash vendored trees vs the lock (tamper/drift)
atlantide component vendor # rebuild .atlantis/ from the lock alone
atlantide component lock # re-resolve declared refs -> commits
```
```python
from atlantide.components.acme import SecureBucket
SecureBucket("assets", bucket="acme-assets")
```
`add` records the source under `[components.acme]` in `atlantide.toml` and the
resolved pin in `atlantide.lock`. **Commit `atlantide.lock` and git-ignore
`.atlantis/`**, which is derived from it. A `build` artifact records each
component's commit as provenance.
## Worked examples
The atlantide-org organisation publishes two components. Each is a small
repository with its source in `src/` and tests covering the expansion, the diff
and the plan:
- **[aws-remote-state](https://github.com/atlantide-org/aws-remote-state)**: a
versioned S3 bucket, a TLS-only bucket policy and a DynamoDB lock table, plus
an optional access role and policy. This is the backend
[remote state](https://atlantide-org.github.io/reference/remote-state.md) requires.
- **[aws-static-website](https://github.com/atlantide-org/aws-static-website)**:
a private origin bucket, an origin access control, a CloudFront distribution
and a bucket policy scoped to that distribution by `AWS:SourceArn`.
Both are added the same way. Pass a tag as `--ref`; `add` resolves it to a
commit:
```bash
atlantide component add https://github.com/atlantide-org/aws-remote-state \
--ref --as acme --subdir src
```
**[component-template](https://github.com/atlantide-org/component-template)** is
a starting point for a new component, with the layout, CI workflow and test suite
in place, and a `scripts/rename.py` that renames the placeholder.
> **A published component is trusted code**
>
> Like a provider, a component runs with the same access as the engine. Review
> it before adding it. After that, integrity rests on the pin: `verify`
> re-hashes every vendored tree and fails on any tampering or drift.
# CI and automation
The first half of the pipeline performs no I/O, so per-commit checks need no
provider credentials. A typical setup validates on every commit, plans on pull
requests, and applies on merge.
| Stage | Command | Needs |
|---|---|---|
| pre-commit | `atlantide validate` | nothing |
| pull request | `atlantide plan --detailed-exitcode` | read access to state |
| merge to main | `atlantide apply -y` | provider credentials, write access to state |
## Validate in a pre-commit hook
`validate` checks that the config stays within the Atlas-lang subset and that
its graph is acyclic. It touches no state and calls no provider:
```yaml title=".pre-commit-config.yaml"
repos:
- repo: local
hooks:
- id: atlantide-validate
name: atlantide validate
entry: atlantide validate
language: system
pass_filenames: false
files: ^(infra\.py|atlantide\.toml)$
```
## Plan as a pull-request gate
Without `--detailed-exitcode`, a plan with pending changes and a plan with none
both exit `0`. With it:
| Code | Meaning |
|---|---|
| `0` | No changes. |
| `1` | An error, or a mandatory [policy](https://atlantide-org.github.io/reference/policies.md) denied the plan. |
| `2` | Changes pending. |
A policy denial exits `1` in both modes, so it is distinct from pending changes.
## Apply on merge
A mutating command in CI needs `-y` to skip the confirmation prompt. Also
configure:
- **Concurrency.** Two applies over the same stack serialize on the state
lease; see [Concurrency](https://atlantide-org.github.io/reference/remote-state.md#concurrency). Queue deploys per
state backend rather than cancelling one mid-run.
- **Failure policy.** `--on-failure rollback` (the default) undoes the nodes a
failed run completed; `halt` leaves them for a human. See
[When apply fails](https://atlantide-org.github.io/how-it-works/failure.md).
## GitHub Actions
```yaml title=".github/workflows/infra.yml"
name: Infrastructure
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
id-token: write # for OIDC to AWS
concurrency:
group: infra-${{ github.ref }}
cancel-in-progress: false # never cancel an apply half-way
jobs:
infra:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
- run: uv tool install atlantide
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.ATLANTIDE_ROLE_ARN }}
aws-region: eu-north-1
- run: atlantide validate
- name: Plan
if: github.event_name == 'pull_request'
run: |
set +e
atlantide plan --detailed-exitcode
code=$?
[ "$code" -eq 2 ] && echo "::notice::changes pending"
[ "$code" -eq 1 ] && exit 1
exit 0
- name: Apply
if: github.ref == 'refs/heads/main'
run: atlantide --audit-log audit.jsonl apply -y
```
This requires a shared [state backend](https://atlantide-org.github.io/reference/remote-state.md) in `atlantide.toml`;
the default local SQLite file does not persist between CI runs. If the config
imports a third-party provider, install it into the same environment
(`uv tool install atlantide --with acme-atlantide`); the
[standalone binary](https://atlantide-org.github.io/install.md#provider-plugins-and-the-binary) cannot load
plugins.
## Build once, deploy the same bytes
`build` compiles the config into a content-hashed `.atlas` artifact with
provider versions pinned. `deploy` applies an artifact without executing the
config again, so production receives the same bytes that were tested in
staging:
```bash
atlantide build -o release.atlas # in the build job
atlantide verify release.atlas # hash check, anywhere
atlantide -P staging deploy release.atlas -y # then, unchanged:
atlantide -P prod deploy release.atlas -y
```
`--env` given to `build` is recorded in the artifact, so a `deploy` covers the
same environments the build did.
## Machine-readable output
`--json` replaces the rendered output with a JSON document on stdout. It is
accepted by `plan`, `apply`, `validate`, `refresh`, `import`, `init`, `output`,
`providers`, `state list` and `state show`. Every payload from a command that
touches state carries a `state` field naming the backend it used.
```bash
atlantide plan --json > plan.json
atlantide output --json
```
Logging is separate from command output and goes to stderr. The options are
global, so they come **before** the command:
```bash
atlantide --log-level info --log-format json plan
atlantide --audit-log audit.jsonl apply -y # append every event to a JSONL file
```
| Global option | Environment variable |
|---|---|
| `--profile/-P` | `ATLANTIDE_PROFILE` |
| `--log-level` | `ATLANTIDE_LOG_LEVEL` |
| `--audit-log` | `ATLANTIDE_AUDIT_LOG` |
| `--debug` | — |
| `--no-plugins` | — |
The Postgres backend also reads its DSN from `ATLANTIDE_STATE_DSN`, which keeps
the password out of `atlantide.toml`.
# Troubleshooting
Each error name identifies the failed stage. `--debug` before any command
(`atlantide --debug plan`) prints the full traceback and cause chain.
Stage numbers refer to [the pipeline](https://atlantide-org.github.io/how-it-works/index.md#the-whole-pipeline-on-one-page).
## Config does not compile (stages 1–2)
**`LanguageError`**: a construct outside the Atlas-lang subset. The message
names the construct, the line, and the replacement:
```
error: construct 'While' is not allowed in Atlas-lang — Atlas-lang has no
`while` (halting must be provable); use a bounded `for`. (line 2, col 0)
```
See [what config may not do](https://atlantide-org.github.io/reference/authoring.md#what-config-may-not-do). `import os`
fails the same way; imports are restricted to `atlantide.*`.
**`FuelExhaustedError`**: evaluation exceeded its step budget, usually from
runaway recursion (not rejected at parse time) or an oversized loop.
**`IRError`**: a value in a resource field cannot be canonicalized. Config data
must be plain JSON-shaped values.
**`RegistryError`**: an unknown or duplicate name, or a bad version: two
resources with one logical name in a stack, an uninstalled provider, or a
component alias that was never added.
## The graph is not a DAG (stage 4)
**`CycleError`**: resources depend on each other in a loop, named in the
message, typically two resources reading a field from each other. Pass a literal
on one side or split a resource.
**`StackOutputCycleError`**: cross-stack `output()` references form a loop, for
example `common:vpc_id -> dev:x -> common:vpc_id`. It is raised before lowering,
so the chain names output keys, not resources.
## The plan is refused (stage 7)
**`PreventDestroyError`**: a planned destroy targets a resource with
`Lifecycle(prevent_destroy=True)`, which `--replace` and `destroy` also honour.
To destroy it, remove the guard and re-plan.
**`PolicyViolationError`**: one or more mandatory policies failed and the apply
is blocked. `plan` exits `1`. Fix the resource, or set the binding to
`level=PolicyLevel.ADVISORY` to warn instead of block. See
[Policies](https://atlantide-org.github.io/reference/policies.md).
**`PolicyConfigError`**: a policy binding passes arguments the policy does not
accept, e.g. `enforce("require-tags", keys=3)`. The message names both.
## Locking and concurrency (stage 8)
**`LockError`**: another run holds the lease. Nothing was written. Wait, or
inspect holders with `atlantide state unlock`.
**`LeaseLostError`**: the run wrote to the provider, then found its lease taken
by another run. **Nothing is rolled back**, because a compensation is a state
write and the run no longer holds the lock. Run `atlantide refresh` before
applying again.
**`FencedWriteError`**: the store refused a write because another run is the
recorded holder. Unlike `LeaseLostError`, the store decides, not the run's local
clock, which can wrongly consider the lease valid. This conditional write
prevents two concurrent runs from silently merging state.
**`PlanDriftError`**: the changeset about to run differs from the approved one.
Apply re-diffs after acquiring the lease so that a resource created by another
run in the meantime is not built twice. The message names what was added and
what disappeared:
```
state changed between the plan you approved and the lock being taken, so the
changes are no longer the ones shown — now also: delete aws.S3Bucket:old.
Re-run to plan against current state.
```
Re-run. The difference is not reconciled automatically because it can contain
an unreviewed destroy.
## During apply
**`ProviderError`**: a provider CRUD call failed. It carries `node_id`, `op`
and `resource_type`. Under the default `--on-failure rollback`, completed nodes
are undone; see
[When apply fails](https://atlantide-org.github.io/how-it-works/failure.md).
**`RollbackError`**: a compensation (a provider call followed by a state write)
did not complete after a failed apply. State can describe a resource that no
longer exists, and because the stored hash still matches config, **the next plan
reports `NOOP`**. Run `atlantide refresh` before the next plan. Raised alongside
the original failure, never instead of it.
**`InterruptedRunError`**: Ctrl-C. Exits `130` instead of `1`, without the
`error:` prefix. Completed nodes are compensated on exit where the run still
held its lock.
## State, secrets, artifacts, components
**`StateError`**: a state backend operation failed. For a remote backend,
`atlantide state check` verifies reachability and setup in one pass.
**`SecretsError`**: sealing or unsealing failed: unknown backend, wrong key, or
corrupt ciphertext. If `plan` warns that *every* secret appears rotated and none
intact, the keyfile is wrong. See [Secrets](https://atlantide-org.github.io/reference/remote-state.md#secrets).
**`ArtifactError`**: a `.atlas` artifact is malformed, corrupted, or fails its
hash check. `atlantide verify` runs that check on its own.
**`ComponentError`**: fetching, vendoring or verifying a component failed: a bad
git source, a missing `--subdir`, or a vendored tree whose hash does not match
`atlantide.lock`. `atlantide component vendor` rebuilds
`.atlantis/` from the lock; `component verify` re-hashes it.
## Things that are not errors
**A plugin that failed to load.** `atlantide providers` lists it and exits `1`.
Config cannot find its types, which resembles a typo. `--no-plugins` ignores all
installed plugins.
**A plan that shows changes you did not make.** The diff compares desired inputs
against *recorded* inputs, not the live world: the config or the state
changed. To compare against the cloud, run `atlantide refresh`.
**A resource the provider could not find during `refresh`.** It is reported but
kept unless `--prune` is given, because discarding the record would make the
next apply create a second one.
# How it works
This section describes the engine in three levels of detail.
- **This page** — the mental model, the pipeline, and which command runs which
stages.
- **[Walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md)** — the pipeline run end to end against local
files. It needs no credentials or AWS account.
- **[The Merkle hash](https://atlantide-org.github.io/how-it-works/merkle.md)** — how `input_hash` is computed and used, with
real numbers.
- **[When apply fails](https://atlantide-org.github.io/how-it-works/failure.md)** — the apply phases, what is rolled back,
what cannot be, and how a failed rollback is kept visible.
The [glossary](https://atlantide-org.github.io/glossary.md) defines every term below, in pipeline order.
---
## The mental model
Atlantide runs a Python file in a sandbox with no clock, randomness, environment
or network. The resources the file creates become a content-hashed dependency
graph, the IR.
Each node is hashed together with its dependencies, producing an `input_hash`
per node. These hashes are compared against those recorded in a state database;
the difference between the two sets is the plan.
Apply walks the graph in topological order, in parallel, and calls providers
only for nodes whose hash changed.
This has two consequences:
**`plan` needs no credentials.** Everything up to the diff is pure computation
over the config and the recorded state.
**The diff compares desired inputs against recorded inputs, not against the live
world.** Detecting infrastructure changed outside Atlantide is a separate
command, `refresh`. The
[walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md#11-drift-and-the-limit-of-the-model) shows where
that boundary sits and what it costs.
---
## The whole pipeline on one page
```
infra.py
│
│ ─── pure: no I/O, no credentials, byte-reproducible ───
│
├─ 1 Atlas-lang validate_source + Interpreter.run lang/validate.py, lang/interp.py
├─ 2 Lower lower(registry, providers) ir/lower.py
├─ 3 Canonicalize RFC 8785 + sha256 ir/hash.py
├─ 4 Graph build_graph (Tarjan) + Kahn graph/build.py, graph/order.py
├─ 5 Merkle merkle_hashes -> input_hash/node ir/merkle.py
│
│ ─── touches the world ───
│
├─ 6 Diff vs prior hashes from state reconcile/diff.py
├─ 7 Plan guard + policies reconcile/planner.py, engine/planner.py
└─ 8 Apply lease -> re-diff -> parallel DAG engine/locking.py, reconcile/executor.py
```
The layering is enforced.
[`pyproject.toml`](https://github.com/atlantide-org/atlantide/blob/main/pyproject.toml)
declares import-linter contracts for the order
`cli > engine > reconcile > (policy | providers | state | secrets | lang) > graph > ir > core`,
plus three rules: `core` imports no sibling package, and `providers` and
`secrets` depend only on `core`.
The contracts run in CI with `mypy --strict` and a 90% coverage floor. An import
that crosses a layer sideways fails the build.
---
## Command reference
| Command | Runs stages | Needs credentials | Writes state |
|---|---|---|---|
| `validate` | 1–5 | no | no |
| `graph` | 1–4 | no | no |
| `build` | 1–5 | no | no |
| `verify` | — | no | no |
| `plan` | 1–7 | no | no |
| `apply` | 1–8 | yes | yes |
| `deploy` | 6–8 (from artifact) | yes | yes |
| `refresh` | provider `read` only | yes | only with `--write` |
| `import` | 1–5, then provider `read` only | yes | yes (records; creates nothing) |
| `destroy` | 6–8, empty desired graph | yes | yes |
| `init` | — (compiles the config it scaffolds) | no | no |
The mutating commands share a set of flags: `--confirm/-y`, `--state`,
`--region`, `--parallelism/-p`, `--on-failure rollback|halt` (defaulting to
`rollback`), `--target/-t` and `--replace`. `--audit-log` is a global option,
given before the command. `--on-failure` is described in
[When apply fails](https://atlantide-org.github.io/how-it-works/failure.md).
`--env/-e` narrows a run to named environments of the config's
[`Config`](https://atlantide-org.github.io/reference/configuration.md#environments). `plan`, `apply`,
`validate`, `build` and `destroy` take it; `refresh` does not.
Defaults come from `atlantide.toml`, looked up from the current directory
upwards. It sets the config path, the state backend, the secrets
provider, parallelism, AWS region, profile and endpoint, component pins, inputs,
and any `[profile.]` overlays selected with `-P`. See
[Configuration](https://atlantide-org.github.io/reference/configuration.md).
# Walkthrough
This page runs the whole pipeline without credentials. Every command below was
run, and every output block is the output it produced.
The example uses the `local` provider, which writes files to disk, so no AWS
account is needed. The pipeline is the same one the AWS provider uses; only the
`create`, `read`, `update` and `delete` implementations differ.
This page assumes the [mental model](https://atlantide-org.github.io/how-it-works/index.md#the-mental-model).
---
## 1. Set up the example
```bash
mkdir demo && cd demo
```
`demo/infra.py`:
```python
"""A two-resource walkthrough config, credentials-free."""
from atlantide.core import Stack, output
from atlantide.providers.local import File
with Stack("demo", region="eu-north-1"):
greeting = File("greeting", path="out/greeting.txt", content="hello atlantide")
# Reading `greeting.checksum` returns a lazy Ref -> a real dependency edge.
File("receipt", path="out/receipt.txt", content=greeting.checksum)
output("greeting_checksum", greeting.checksum)
```
The config has three parts:
| Line | What it is |
|---|---|
| `with Stack("demo", region=...)` | A namespace. Every resource inside gets node id `demo::`. `region` is required even for the local provider — it is stack metadata, not a provider detail. |
| `greeting.checksum` | `checksum` is a `computed()` field — its value does not exist yet. Reading it returns a lazy `Ref` ([`core/types.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/core/types.py)), which is what creates the dependency edge. No `depends_on`, no string addresses. |
| `output(...)` | A declared output, persisted into state after apply. Declared *outside* the `with` block here, so it lands in the `default` namespace (`default:greeting_checksum`). Move it inside the block and it becomes `demo:greeting_checksum` and is readable by another stack. |
`File` itself declares its per-field mutability
([`providers/local/resources.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/providers/local/resources.py)):
```python
class File(LocalResource):
path: str = immutable() # changing it => REPLACE
content: str = mutable(default="") # changing it => in-place UPDATE
checksum: str = computed() # never diffs; produced by the provider
```
This declaration tells the engine that a path change requires a recreate and a
content change does not. The cost is visible in the type before anything runs.
---
## 2. Validate — the pure front half, on its own
```bash
atlantide validate infra.py
```
```
ok infra.py — 2 resource(s), no cycles
```
It uses no state file, credentials or network access, which makes it suitable
for a pre-commit hook. It runs pipeline stages 1–5 (sections 3–6 below) and stops.
### The sandbox is real, not a convention
```bash
# a while loop
atlantide validate bad.py
```
```
error: construct 'While' is not allowed in Atlas-lang — Atlas-lang has no
`while` (halting must be provable); use a bounded `for`. (line 2, col 0)
2 | while True:
^
```
```bash
# import os
atlantide validate bad.py
```
```
error: import of 'os' is not allowed (only 'atlantide.core', '.policy',
'.providers.*', '.components.*') — config must be a pure function of its inputs;
move helpers into a provider or use Atlas builtins (`uuid5`, `sha256_hex`,
`to_json`, `merge`, `slugify`). (line 1, col 0)
1 | import os
^
```
Also rejected: `class`, `try`, dunder access, `eval`, `str.format`, and attribute
assignment such as `a.content = ...`. (The single `class` exception is an
[`EnvSchema`](https://atlantide-org.github.io/reference/configuration.md#the-schema) declaring an
environment's fields — annotated names only, no methods, so it is data.)
Because of the last restriction, a dependency cycle is close to unwritable:
references can only be wired at construction time, and construction is ordered.
---
## 3. Stage 1 — Atlas-lang: subset check, then bounded evaluation
**Code trace**
```
Engine.compile() engine/engine.py
└─ evaluate_source() lang/__init__.py
├─ build_globals(inputs) lang/builtins.py # safe builtins + uuid5, sha256_hex, to_json, merge, slugify
├─ validate_source() lang/validate.py # ast walk; rejects the constructs above
└─ _run_module() lang/__init__.py
├─ collecting() core/resource.py # a context that captures every Resource constructed
└─ Interpreter(fuel=...).run lang/interp.py # tree-walking, fuel-bounded
```
It is a **tree-walking interpreter**, not `exec`. The interpreter never binds an
`os` module.
Evaluation is **fuel-bounded**: a step budget prevents a config from hanging the
engine. With the ban on `while`, this makes termination provable.
Every exception that is not already an `AtlantideError` is converted to a
`LanguageError`, so a broken config produces a configuration error rather than
an engine stack trace.
Output: a `ResourceRegistry` ([`core/resource.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/core/resource.py))
holding the resources, the declared outputs, the policy bindings and the inputs
the config read.
---
## 4. Stage 2 — Lowering to IR
```
Engine._compile_registry() engine/engine.py
├─ inline_stack_outputs(registry) core/inline.py # fold in-config cross-stack refs into real Ref edges
└─ lower(registry, providers) ir/lower.py
```
Each `Resource` becomes an `IRNode` ([`ir/model.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/ir/model.py)):
canonical inputs, the dependency ids taken from `resource.refs()`, and the
provider name **and version** stamped in.
`build` writes the IR to a file (step 12):
```json
{
"nodes": [
{
"dependencies": [],
"id": "demo:local.File:greeting",
"properties": {
"content": "hello world",
"path": "out/greeting.txt"
},
"provider": "local",
"provider_version": "1.0.0",
"type": "local.File"
},
{
"dependencies": [
"demo:local.File:greeting"
],
"id": "demo:local.File:receipt",
"properties": {
"content": {
"$ref": "demo:local.File:greeting#checksum"
},
"path": "out/receipt.txt"
},
"provider": "local",
"provider_version": "1.0.0",
"type": "local.File"
}
],
"version": 1
}
```
`greeting.checksum` had no value at config time; it is now the data
`{"$ref": "demo:local.File:greeting#checksum"}`, and `dependencies` records the
edge it implies. The lazy attribute read has become a serialisable graph edge.
---
## 5. Stage 3 — Canonicalize and hash
```
canonical_bytes() ir/hash.py # RFC 8785 JSON Canonicalization Scheme
hash_ir() ir/hash.py # sha256 over those bytes
```
RFC 8785 fixes key order, number formatting and string escaping, so the same IR
always serialises to the same bytes. This makes the content hash stable and the
`.atlas` artifact portable.
To verify, build twice and compare:
```bash
atlantide build infra.py -o a.atlas
atlantide build infra.py -o b.atlas
shasum -a 256 a.atlas b.atlas
```
```
built a.atlas — 2 nodes, hash f9ecb89e5ade…, pins {'local': '1.0.0'}
built b.atlas — 2 nodes, hash f9ecb89e5ade…, pins {'local': '1.0.0'}
6f10115bf10968dfda4081c19041a92183081875ee1f6370f2f8fbed9ba2292c a.atlas
6f10115bf10968dfda4081c19041a92183081875ee1f6370f2f8fbed9ba2292c b.atlas
```
The files are byte-identical, not only their hashes.
---
## 6. Stages 4–5 — Graph and Merkle
```
assemble_compiled() engine/hydrate.py
├─ build_graph(ir) graph/build.py # DiGraph; cycles rejected (iterative Tarjan)
├─ topological_order(g) graph/order.py # Kahn
└─ merkle_hashes(ir, topo) ir/merkle.py # input_hash per node
```
Inspect the graph without applying anything:
```bash
atlantide graph infra.py --format mermaid
```
```
graph LR
subgraph cluster0["demo"]
n0["local.File:greeting"]
n1["local.File:receipt"]
end
n0 --> n1
```
The result of all four pure stages is a `Compiled`
([`engine/model.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/engine/model.py)): IR, graph,
hashes, resources, policy bindings, outputs, inputs.
Every later stage depends on the `input_hash` computed here.
[The Merkle hash](https://atlantide-org.github.io/how-it-works/merkle.md) covers its definition, how a change propagates, and
the two cases the hash cannot see.
---
## 7. Stages 6–7 — Diff and Plan
```bash
atlantide plan infra.py --state demo.db
```
```
state: demo.db
demo ───────────────────────────────────────────────────────────────────────────
+ create local.File:greeting
+ create local.File:receipt
Plan: 2 to add
Outputs:
default:greeting_checksum = (known after apply)
```
**Code trace**
```
Engine.plan() engine/engine.py
└─ _plan_from_compiled() engine/engine.py
├─ resolve_aliases(backend.load(), ir) reconcile/aliases.py # a rename maps onto the existing row, not destroy+create
├─ _selection(...) engine/engine.py # --target / --replace, closed over dependencies
└─ Planner.build() engine/planner.py
├─ diff(ir, hashes, prior, mutability) reconcile/diff.py
├─ force_replace(...) reconcile/diff.py # --replace
├─ restrict(...) reconcile/diff.py # --target
├─ plan(raw, protected) reconcile/planner.py # prevent_destroy guard — AFTER the two above
└─ _refine() engine/planner.py
├─ _audit_secrets / _require_secrets
├─ _require_stack_outputs
└─ _finalize -> _resolve_cbd + _evaluate_policies
```
The diff compares the desired `input_hash` against the `input_hash` stored on the
prior state node. State is empty here, so both nodes are creates. When a hash
differs,
[`_classify`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/diff.py)
uses the per-field mutability map to choose between an update and a
replacement.
Policies run at the end of planning, against the finished changeset. A mandatory
violation sets `Plan.blocked`, and `apply` refuses before touching anything.
Machine-readable form for CI:
```bash
atlantide plan infra.py --state demo.db --json
```
```json
{
"schema_version": 1,
"ok": true,
"summary": { "create": 0, "update": 0, "replace": 0, "delete": 0, "noop": 2 },
"inputs": {},
"changes": [
{
"node_id": "demo:local.File:greeting",
"action": "noop",
"changed_fields": [],
"conditional": false,
"create_before_destroy": false
}
],
"outputs": { "default:greeting_checksum": null },
"violations": [],
"warnings": [],
"blocked": false,
"state": "demo.db"
}
```
`--detailed-exitcode` returns `0` for no changes, `2` for changes pending and
`1` for an error.
---
## 8. Stage 8 — Apply
```bash
atlantide apply infra.py --state demo.db -y
```
```
state: demo.db
demo ───────────────────────────────────────────────────────────────────────────
+ create local.File:greeting
+ create local.File:receipt
Plan: 2 to add
demo ───────────────────────────────────────────────────────────────────────────
+ done local.File:greeting
+ done local.File:receipt
Applied: 2 to add (0.0s)
Outputs:
default:greeting_checksum = 1caa462dd96fe06c4e4afa9476f5a2c59c39270f21a09e5a65602daee9def0e0
```
```bash
cat out/greeting.txt # hello atlantide
cat out/receipt.txt # 1caa462dd96fe06c4e4afa9476f5a2c59c39270f21a09e5a65602daee9def0e0
```
`receipt`'s content is `greeting`'s sha256. The `Ref` resolved at apply time,
after `greeting` was created.
**Code trace**
```
Engine.apply() engine/engine.py
└─ _apply_compiled() engine/engine.py
├─ apply_scope(plan, prior) engine/locking.py # a pre-lock plan sizes the lease scope
└─ _locked(run_replanned, scope, prepare=_alias_migration(ir))
└─ with_lock() engine/locking.py
├─ backend.acquire_lock(owner, ttl, scope)
├─ backend.bind_lease(lease) # fences every subsequent write
├─ background lease renewal
└─ run_replanned() engine/engine.py
├─ re-diff against freshly-loaded state
├─ raise_drift(approved, fresh) engine/drift.py
└─ executor.apply() -> _Applier.run() -> _run_phases()
reconcile/executor.py
```
**The lock scope is the reachable graph, not the whole state.** Two runs touching
disjoint subgraphs do not block each other.
**The lease is renewed for as long as the run lives, and writes are fenced.** A
[`LeaseGuard`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/state/backend.py)
makes the store refuse a write from a lease that no longer holds the lock, so a
stalled run that resumes cannot corrupt state.
**Apply re-diffs under the lock**, because another run may have created a node in
the meantime. What executes could then differ from what was approved, so the
approved changeset is passed in as `expect` and any difference raises
`PlanDriftError`. `--allow-plan-drift` disables the check.
**Execution is a DAG scheduler**,
[`run_graph`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/graph/schedule.py).
Each node awaits its predecessors' `asyncio.Event` and only then takes the
concurrency semaphore. Awaiting dependencies before acquiring the semaphore
prevents a low `--parallelism` from deadlocking a deep graph. Each node is
wrapped in `asyncio.timeout(node_timeout)`.
**Execution runs in phases**: first create, update and replace over the desired
graph in topological order; then `_cbd_cleanup`, destroying the prior halves of
create-before-destroy replacements; then deletes over the *prior* graph in reverse
order.
On failure — including a `CancelledError` from Ctrl-C — the default
`--on-failure rollback` runs the recorded compensations in reverse. Rollback
failures surface as an `ExceptionGroup`. State is persisted one node at a time,
so a crash part-way through the graph leaves an accurate record.
---
## 9. Re-apply — the Merkle skip
```bash
atlantide apply infra.py --state demo.db -y
```
```
demo ───────────────────────────────────────────────────────────────────────────
= noop local.File:greeting
= noop local.File:receipt
Plan: 2 unchanged
nothing to apply
```
No provider calls were made; the engine compared hashes and stopped. With the AWS
provider, this avoids hundreds of API calls per re-run.
---
## 10. UPDATE vs REPLACE — per-field mutability
### Change a `mutable()` field
Edit `content` to `"hello world"`:
```bash
atlantide plan infra.py --state demo.db
```
```
demo ───────────────────────────────────────────────────────────────────────────
~ update local.File:greeting
content: 'hello atlantide' → 'hello world'
~ update local.File:receipt
content: {'$ref': 'demo:local.File:greeting #checksum'} → {'$ref':
'demo:local.File:greeting #checksum'}
Plan: 2 to change
```
`receipt`'s own input did not change — the `$ref` is identical on both sides —
yet it is an update.
This is Merkle propagation. `receipt`'s `input_hash` includes `greeting`'s hash,
so a change to `greeting` forces `receipt` to be reconciled, because the value
its reference resolves to is about to change.
[The Merkle hash](https://atlantide-org.github.io/how-it-works/merkle.md#watch-the-ripple-with-real-numbers) shows the two
hashes that differ and the single `deps` entry that caused it.
```bash
atlantide apply infra.py --state demo.db -y
```
```
Applied: 2 to change (0.0s)
Outputs:
default:greeting_checksum = b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
```
`out/receipt.txt` now holds the new checksum.
### Change an `immutable()` field
Edit `path` to `"out/hello.txt"`:
```
demo ───────────────────────────────────────────────────────────────────────────
± replace local.File:greeting
path: 'out/greeting.txt' → 'out/hello.txt'
~ update local.File:receipt
...
Plan: 2 to change
```
The result is `± replace` rather than `~ update`, determined by the
`immutable()` marker on the field. The downstream node is updated in both cases.
---
## 11. Drift, and the limit of the model
Modify the file outside the engine:
```bash
echo "TAMPERED" > out/greeting.txt
atlantide refresh --state demo.db -v
```
```
demo ───────────────────────────────────────────────────────────────────────────
~ drifted local.File:greeting (1 of 2 inputs checked)
checksum: 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2… →
'314cbe4410c173d9716307a8d25f58f61f3afe467a47f0272020ff3db2…
not checked: content
= in sync local.File:receipt (1 of 2 inputs checked)
not checked: content
Refresh: 1 drifted (state unchanged)
```
**`1 of 2 inputs checked`, and `not checked: content`.** Drift is only visible in
fields that the provider's `read` reports.
[`LocalProvider.read`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/providers/local/provider.py)
returns `checksum` and `path`, not `content`. Atlantide reports unchecked fields
instead of marking them in sync. `-v` names the unchecked fields; without it,
the output includes a one-line notice.
**`state unchanged`.** `refresh` is read-only by default. Add `--write` to record
what it found back into state.
The next step shows where the model differs from Terraform's:
```bash
atlantide refresh --state demo.db --write # -> "1 drifted (state updated)"
atlantide plan infra.py --state demo.db
```
```
= noop local.File:greeting
= noop local.File:receipt
Plan: 2 unchanged
```
The modified file is not repaired. The diff compares desired inputs against
recorded inputs, and neither changed: `content` drifted but the provider cannot
report it, and `checksum` drifted but is `computed()`, so it never contributes to
a diff. The input set and the hash are unchanged, so the node is a no-op.
To force the repair:
```bash
atlantide apply infra.py --state demo.db -y --replace 'local.File:greeting'
```
```
± replace local.File:greeting
(forced): None → None
= noop local.File:receipt
Applied: 1 to change, 1 unchanged (0.0s)
```
`out/greeting.txt` is `hello world` again.
Hash-based diffing gives re-runs without provider calls and exact propagation.
The trade-off is that out-of-band changes to fields a provider cannot read are
invisible to `plan`. `refresh` detects drift, and `--replace` forces the repair.
---
## 12. Artifacts — build once, deploy anywhere
```bash
atlantide build infra.py -o demo.atlas
```
```
built demo.atlas — 2 nodes, hash f9ecb89e5ade…, pins {'local': '1.0.0'}
```
The `.atlas` file is the full canonical IR (shown in step 4) plus `ir_hash`,
`provider_pins`, `component_pins`, `policies` and `outputs`
([`ir/artifact.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/ir/artifact.py)).
```bash
atlantide verify demo.atlas
```
```
ok demo.atlas: hash and provider pins verified
```
`deploy` applies an artifact **without re-executing the config**:
`verify_artifact` → `rehydrate_resources`
([`engine/hydrate.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/engine/hydrate.py)) rebuilds
live `Resource` objects from the IR → the same `_apply_compiled` path as `apply`.
This supports promotion: build in CI, verify the hash, then deploy the identical
bytes to staging and to production. The config cannot evaluate differently in the
second environment, because it is not evaluated again.
---
## 13. Inspecting state
```bash
atlantide state list --state demo.db
```
```
2 resource(s)
┏━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━┓
┃ node ┃ type ┃ status ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━┩
│ demo:local.File:greeting │ local.File │ created │
│ demo:local.File:receipt │ local.File │ created │
└──────────────────────────┴────────────┴─────────┘
```
```bash
atlantide output --state demo.db
```
```
default:greeting_checksum = b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
```
Also available: `state show`, `state backup` / `restore`, `state rm` (forget a
row without touching the provider), `state unlock` (break a dead run's lease),
`state check`, `state migrate`.
---
## 14. Destroy
```bash
atlantide destroy --state demo.db -y
```
```
demo ───────────────────────────────────────────────────────────────────────────
- destroy local.File:greeting
- destroy local.File:receipt
Plan: 2 to destroy
demo ───────────────────────────────────────────────────────────────────────────
- done local.File:receipt
- done local.File:greeting
Destroyed: 2 resource(s) (0.0s)
```
The plan lists the nodes in graph order, but execution runs `receipt` before
`greeting`, because deletes run in reverse topological order.
`destroy` is implemented as a diff against an *empty* desired graph
([`engine/engine.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/engine/engine.py)), and
its `--target` closure runs over **dependents** rather than dependencies: destroy
a VPC and it pulls in everything sitting on top of it, not everything underneath.
`out/` is empty and `state list` reports `state is empty`.
---
## 15. Where to go next
- **Secrets** — `atlantide.secret("name")` is a *handle*; plaintext resolves in
memory at apply and never enters the IR. Sensitive outputs are sealed at rest.
See [`secrets/`](https://github.com/atlantide-org/atlantide/tree/main/atlantide/secrets/).
- **Policies** — `enforce("require-tags", keys=["env"])` at config top level;
mandatory violations block `apply`. See [Policies](https://atlantide-org.github.io/reference/policies.md).
- **Components (L2)** — publishable from a git repo, pinned to commit + content
hash, vendored locally. Worked example in
[`examples/components/`](https://github.com/atlantide-org/atlantide/tree/main/examples/components/).
- **A real AWS graph** — [`examples/aws/example-one.py`](https://github.com/atlantide-org/atlantide/blob/main/examples/aws/example-one.py):
per-env stacks, cross-stack outputs, IAM, Lambda with `code_path`
fingerprinting, `SecretRef`, two enforced policies.
- **Writing a provider** — a provider is an ordinary Python package exposing a
`ProviderPlugin` on the `atlantide.providers` entry-point group. The `local`
provider is 80 lines and is the shortest complete example:
[`providers/local/`](https://github.com/atlantide-org/atlantide/tree/main/atlantide/providers/local/).
- **When it goes wrong** — [When apply fails](https://atlantide-org.github.io/how-it-works/failure.md) covers rollback and
its limits; [Troubleshooting](https://atlantide-org.github.io/reference/troubleshooting.md) indexes every
error by the stage that raises it.
- **Full reference** — the [CLI](https://atlantide-org.github.io/reference/cli.md), [Authoring](https://atlantide-org.github.io/reference/authoring.md),
[Configuration](https://atlantide-org.github.io/reference/configuration.md) and
[Remote state](https://atlantide-org.github.io/reference/remote-state.md) pages.
# The Merkle hash
The `input_hash` makes a re-run free of provider calls, propagates a change to
exactly the affected nodes, and lets `plan` run without credentials.
The [walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md) reaches this point at step 6, once the graph
has been built.
---
## The definition
From [`ir/merkle.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/ir/merkle.py):
```
h(n) = sha256( canonical({ type, properties, [h(d) for d in sorted(deps(n))] }) )
```
The implementation
([`ir/merkle.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/ir/merkle.py)):
```python
def merkle_hashes(ir: IRGraph, topo_order: Sequence[str]) -> dict[str, str]:
by_id = {node.id: node for node in ir.nodes}
hashes: dict[str, str] = {}
for node_id in topo_order:
node = by_id[node_id]
ignored = set(node.ignore_changes)
properties = (
{k: v for k, v in node.properties.items() if k not in ignored}
if ignored else node.properties
)
payload = {
"type": node.type,
"properties": properties,
"deps": [hashes[dep] for dep in sorted(node.dependencies)],
}
hashes[node_id] = hashlib.sha256(to_canonical_json(payload)).hexdigest()
return hashes
```
Four details of the function:
| Detail | Why |
|---|---|
| **Iterated in `topo_order`** | Guarantees `hashes[dep]` is already computed when a node needs it. The Kahn ordering therefore runs first, and a cycle is a hard error — a cyclic graph has no fixpoint for this recursion. |
| **`sorted(node.dependencies)`** | Only the dependency *set* affects the hash, not its order, so reordering config does not register as a change. |
| **`to_canonical_json`** | RFC 8785. Key order, number format and string escaping are fixed, so the same logical payload always produces the same bytes on any machine, any Python version. |
| **`ignore_changes` fields removed before hashing** | A field listed in `ignore_changes` cannot move the hash, so it never triggers an UPDATE or REPLACE. The diff drops the same fields, so the two agree. |
## It is a DAG fold, not a classic Merkle tree
A classic Merkle tree is a binary tree over a list, built so that membership of
one leaf can be proved with O(log n) sibling hashes. None of that applies here:
there are no sibling proofs, no root hash used for verification, and no balanced
structure.
Only the recursive-hash property is borrowed: a node's hash is a function of its
own content plus its children's hashes, so any change propagates to every
ancestor. Here the structure is the resource DAG, "children" are dependencies,
and there is one hash per node rather than a single root.
The root-hash role ("did the whole config change?") is filled separately by
`ir_hash` over the entire IR
([step 5](https://atlantide-org.github.io/how-it-works/walkthrough.md#5-stage-3-canonicalize-and-hash)).
The structure is a *content-addressed dependency graph*.
## Watch the ripple, with real numbers
Same two-resource config, hashed under three variants:
- **A** baseline — `path="out/greeting.txt"`, `content="hello atlantide"`
- **B** mutable change — `content="hello world"`
- **C** immutable change — `path="out/hello.txt"`
```
demo:local.File:greeting
A baseline a0de4c9548771eba8f0e405ce45cd5abeeee898994db49a334c208b831e9f9bd
B mutable change 6c1673dc289138d0a7ddb87bebc280c85492b5bca8cd936863c5010d775a40c2
C immutable change 01d15e92cad556ebb9279d3c30c84dc5f4042157ea3bd4830ecebb251be4975b
demo:local.File:receipt
A baseline d1c54122b306e5ee15c79032364658d9151835a7e7ace777b65ba7b36715862a
B mutable change eae4a250263c2385c2c58f1301f12d2f27bf8fc65bd0293cd0e6f55e0908bbf8
C immutable change ab516e29d39b2760d74f40ec1972efd8602dab6d1b23ffdfa0888054cba5f84b
```
`receipt`'s hash changes in all three variants, although `receipt` was never
edited. Its own properties are byte-identical across all three:
```
receipt's OWN properties, per variant (canonical bytes):
A {"content":{"$ref":"demo:local.File:greeting#checksum"},"path":"out/receipt.txt"}
B {"content":{"$ref":"demo:local.File:greeting#checksum"},"path":"out/receipt.txt"}
C {"content":{"$ref":"demo:local.File:greeting#checksum"},"path":"out/receipt.txt"}
```
The difference is entirely in the `deps` entry. The payload hashed for `receipt`
under variant A:
```json
{
"type": "local.File",
"properties": {
"path": "out/receipt.txt",
"content": { "$ref": "demo:local.File:greeting#checksum" }
},
"deps": [
"a0de4c9548771eba8f0e405ce45cd5abeeee898994db49a334c208b831e9f9bd"
]
}
```
canonicalized:
```
{"deps":["a0de4c95…f9bd"],"properties":{"content":{"$ref":"demo:local.File:greeting#checksum"},"path":"out/receipt.txt"},"type":"local.File"}
sha256 -> d1c54122b306e5ee15c79032364658d9151835a7e7ace777b65ba7b36715862a
```
and under variant B, the identical payload with one substitution:
```json
"deps": [
"6c1673dc289138d0a7ddb87bebc280c85492b5bca8cd936863c5010d775a40c2"
]
```
That field is the whole mechanism. It explains the plan output at
[step 10 of the walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md#10-update-vs-replace-per-field-mutability),
where `receipt` shows `~ update` with an identical `$ref` on both sides of the
arrow: the CLI renders the properties, which did not change, while the diff
reacts to the `deps` entry, which did.
## Why the hash is computed over *symbolic* refs
`properties` keeps `{"$ref": "demo:local.File:greeting#checksum"}` rather than the
resolved checksum. Three consequences follow:
1. **No provider I/O is needed to compute the hash.** The resolved value of that
ref does not exist until `greeting` has been created. If the hash depended on
resolved values, `plan` would need to call the provider, and therefore need
credentials.
2. **Nothing is lost.** A ref's resolved value can only change if the referenced
node changed, and that is already folded in via `deps`. An unchanged hash
therefore implies unchanged resolved inputs.
3. **The hash the executor persists equals the hash the planner computed**
([`reconcile/executor.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/executor.py)),
because both are over the symbolic form. No apply-time recompute step can
disagree with the plan.
## How the diff uses it
In [`reconcile/diff.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/diff.py):
```python
if desired_hashes[node_id] == have.input_hash: # Merkle skip: no provider read
return Change(node_id, Action.NOOP, desired=want, prior=have)
return _classify(want, have, mutability.get(want.type, {}))
```
The full ladder in `_change_for` ([`reconcile/diff.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/diff.py)):
| Condition | Action |
|---|---|
| in prior state only | DELETE |
| in desired IR only | CREATE |
| in both, but `status != created` (a write-ahead row from a crashed create) | CREATE — never NOOP |
| in both, **hashes equal** | **NOOP — no provider `read`, no provider anything** |
| in both, hashes differ | `_classify` → per-field mutability decides UPDATE vs REPLACE |
A no-op does not mean "checked and matched"; it means "not checked". This is the
source of the performance gain and of the limitation described at
[step 11 of the walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md#11-drift-and-the-limit-of-the-model).
When hashes differ, `_classify` needs a set of changed fields. Usually a property
comparison supplies it. In the propagation case the properties are identical, so
[`_changed_fields`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/diff.py)
attributes the change to the ref-bearing fields, whose resolved values are what
moved. This is why the plan names `content` on `receipt`.
## Two cases the hash alone cannot see
The Merkle hash is a function of the config alone. Two situations change a node's
real inputs without changing its hash; each has its own mechanism.
**1. An upstream node is recreated for a *state-side* reason.** Suppose
`greeting` is missing from state, or its create never confirmed. Its config is
unchanged, so its hash and `receipt`'s hash are unchanged, yet `greeting` is
about to get a new physical identity and `receipt`'s resolved input will change. `_stale_dependents`
([`reconcile/diff.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/diff.py)) pulls
those NOOPs back into the changeset, transitively, whenever an upstream action is
CREATE or REPLACE.
**2. A state row stops being trustworthy.** If a rollback compensation calls the
provider and then dies before writing state, the stored row describes something
that no longer exists. Because the diff is symbolic, its stored hash still
matches config, and the next plan would report NOOP indefinitely. The sentinel
`NO_INPUT_HASH` is written into `input_hash` so the comparison can never succeed. Both `_poison`
([`reconcile/executor.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/executor.py))
and `refresh --write` ([`reconcile/refresh.py`](https://github.com/atlantide-org/atlantide/blob/main/atlantide/reconcile/refresh.py))
use it. `_poison` writes it *before* running the compensation; otherwise a
process killed mid-rollback would leave the row clean and the divergence silent.
A poisoned row is classified as UPDATE, not REPLACE. `_changed_fields` checks
`prior.input_hash != NO_INPUT_HASH` before attributing the change to ref-bearing
fields, because a ref-bearing field can also be `immutable()`
(`SecurityGroup.vpc_id`, `Route53Record.zone_id`). Attributing a change there
would turn "re-check this node" into "destroy and recreate working
infrastructure".
## Reproduce the numbers
The hashes above were produced by calling the pure stages directly:
```python
from atlantide.graph.build import build_graph
from atlantide.graph.order import topological_order
from atlantide.ir.lower import lower
from atlantide.ir.merkle import merkle_hashes
from atlantide.lang import evaluate_source
registry = evaluate_source(open("infra.py").read()).unwrap()
ir = lower(registry)
graph = build_graph(ir).unwrap()
print(merkle_hashes(ir, topological_order(graph)))
```
It needs no credentials, state file or network access.
# When apply fails
A plan can fail part-way through. This page describes what the engine does next.
```bash
atlantide apply --on-failure rollback # default: undo what this run completed
atlantide apply --on-failure halt # stop where it stands, undo nothing
```
## The three phases
Apply runs in phases. Only the first is reversible.
| Phase | What runs | Order | Reversible |
|---|---|---|---|
| 1 | `CREATE`, `UPDATE`, `REPLACE` | dependencies first, in parallel | **yes** |
| 1b | the destroy half of a create-before-destroy `REPLACE` | dependents first | no — terminal |
| 2 | `DELETE` | dependents first | no — terminal |
Phases 1b and 2 are terminal: recreating a destroyed resource would lose its
identity and outputs, so a failure there is reported, not compensated. Committed
stack outputs are persisted only after all three phases succeed.
## The compensation saga
Under the default `--on-failure rollback`, every node that completes in phase 1
records an undo. On failure the undos run in **reverse completion order**, not
reverse graph order, so they follow what this run actually did.
| What was applied | What the undo does |
|---|---|
| `CREATE` | `provider.delete`, then drop the state row. The resource carries the outputs of its create — notably its id — so the delete acts on that resource directly rather than locating it by attributes, which could match an unrelated resource sharing them. |
| `UPDATE` | `provider.update` back to the prior values, then restore the prior state row verbatim, still sealed. |
| `REPLACE` | Undo the create half, then restore the prior resource and its recorded outputs. |
The saga catches `BaseException`, not `Exception`. An interrupt arrives as a
cancellation; catching only `Exception` would skip compensation on a Ctrl-C
during an apply that has already created resources.
## When rollback does not run
**A run that lost its lease is not rolled back.** A compensation is a provider
call and a state write, and a run that no longer holds the lock may be racing the
run that took it; undoing "its" creates could destroy the other run's resources.
The resources are left in place, and the report states this.
This is the only case in which `--on-failure rollback` does not roll back. Recover with `atlantide refresh`.
## When rollback itself fails
An undo is a provider call followed by a state write. A failure between the two
leaves state describing a resource that no longer exists. Because the diff is
symbolic, that row's stored hash still matches config, so the next plan would
report `NOOP` and the divergence would stay hidden permanently.
To prevent this, the engine **poisons** the row before running a node's
compensation: it clears the row's `input_hash` so the next plan cannot
Merkle-skip it. The undo's own state write clears the poison when it succeeds.
Poisoning afterwards would only take effect on an exception; a process killed
mid-rollback would leave the row untouched and the divergence silent.
When compensations fail, the original error and the rollback errors are raised
together as a group, and every leaf is rendered. The rollback failure never
replaces the apply failure.
## Interrupts
The first Ctrl-C cancels the run and follows the failure path above: under the
default, completed nodes are compensated on the way out. The exit code is `130`.
A second Ctrl-C abandons that cleanup. Each compensation is *shielded*, so the
press cannot cancel an undo between its provider call and its state write — the
window poisoning makes visible — but the CLI's hard exit still takes effect.
State may then not describe what exists: run `atlantide refresh` before applying
again.
The second press does **not** release the run's lock. An abandoned run may still
have provider calls in flight, so the lease is left to lapse, or to be cleared
with `atlantide state unlock`, rather than handed to a second writer.
## The re-diff before anything runs
Apply does not execute the changeset that `plan` printed. Once it holds the
lease, it re-diffs against freshly loaded state; otherwise a resource that
another run created in the meantime would still be planned as a `CREATE` and
built twice.
The approved plan and the plan about to execute can therefore differ, and that
difference could include an unreviewed destroy. When they differ, the run stops
with a `PlanDriftError` naming what appeared and what disappeared.
## After a failed run
```bash
atlantide refresh # what actually exists, vs what state records
atlantide state list # what state records
atlantide state unlock # who holds the lease
```
`refresh` is the recovery command in every case above. It is read-only unless
given `--write`. A resource the provider cannot find is reported but kept unless
`--prune` is also given.
See [Troubleshooting](https://atlantide-org.github.io/reference/troubleshooting.md) for the errors these paths
raise.
# Building on AWS
This guide builds a multi-environment AWS stack one resource at a time and
introduces each authoring feature where it is first needed.
The finished configuration has a shared VPC in a `common` stack, plus `dev` and
`prod` stacks. Each environment stack holds an S3 bucket, an SQS queue, an IAM
role with an inline policy, a Lambda function that reads a secret, and a security
group attached to the shared VPC: fifteen resources across two providers, wired
together by references.
The end state is
[`examples/aws/example-one.py`](https://github.com/atlantide-org/atlantide/blob/main/examples/aws/example-one.py);
you can compare your file against it at any point.
> **About the output in this guide**
>
> Every `validate`, `plan` and `graph` block below is output captured by
> running the configuration at that step. These commands belong to the pure
> half of the pipeline and require **no AWS credentials**, so you can follow
> the guide without an AWS account and without creating anything.
>
> `apply` output is not shown, because producing it provisions real
> infrastructure. Where an applied value would appear, the plan prints
> `(known after apply)`.
This guide assumes the [walkthrough](https://atlantide-org.github.io/how-it-works/walkthrough.md), which
covers the same pipeline against local files in more depth.
---
## Before you start
```bash
mkdir aws-demo && cd aws-demo
```
`plan` requires nothing further. `apply` requires AWS credentials (environment
variables, `~/.aws/credentials`, or an instance role) with permission to create
the resource types used below.
---
## 1. One bucket, one stack
`infra.py`:
```python
from atlantide.core import Stack, output
from atlantide.providers.aws import Region, S3Bucket
with Stack("dev", region=Region.EuNorth1, name_prefix="atlantide", tags={"env": "dev"}):
assets = S3Bucket("assets", bucket="atlantide-assets-dev-7f3a91c2")
output("assets_arn", assets.arn)
```
```bash
atlantide validate infra.py
```
```
ok infra.py — 1 resource(s), no cycles
```
```bash
atlantide plan infra.py --state infra.db
```
```
state: infra.db
dev ────────────────────────────────────────────────────────────────────────────
+ create aws.S3Bucket:assets
Plan: 1 to add
Outputs:
dev:assets_arn = (known after apply)
```
| | |
|---|---|
| `Stack("dev", ...)` | A namespace. Every resource inside gets node id `dev::` and inherits the stack's `region`, `name_prefix` and `tags`. The same logical name can exist in every stack without colliding. |
| `Region.EuNorth1` | An enum, so a typo is a type error in your editor instead of an API error at apply time. |
| `output(...)` | Declared inside the `with` block, so it is namespaced `dev:assets_arn` and readable by another stack. Declared outside, it lands in `default`. |
`S3Bucket` blocks public access and enables SSE-S3 encryption by default. A
public or unencrypted bucket must be configured explicitly.
---
## 2. A queue, a role, and a policy wired by references
Add an SQS queue, an IAM role, and an inline policy that grants the role access
to the bucket and the queue:
```python
from atlantide.core import Stack, output
from atlantide.providers.aws import (
IamPolicy, IamRole, Region, S3Bucket, ServicePrincipal, SqsQueue, allow,
)
with Stack("dev", region=Region.EuNorth1, name_prefix="atlantide", tags={"env": "dev"}):
assets = S3Bucket("assets", bucket="atlantide-assets-dev-7f3a91c2")
jobs = SqsQueue("jobs", fifo=True)
worker = IamRole("worker", assumed_by=[ServicePrincipal.Ec2, ServicePrincipal.Lambda])
IamPolicy(
"worker-policy",
role_arn=worker.arn,
policy_name="worker-access",
statements=[
allow(S3Bucket.Action.GetObject, S3Bucket.Action.PutObject, on=assets.objects_arn),
allow(SqsQueue.Action.SendMessage, on=jobs.arn),
],
)
output("assets_arn", assets.arn)
```
```bash
atlantide plan infra.py --state infra.db
```
```
state: infra.db
dev ────────────────────────────────────────────────────────────────────────────
+ create aws.IamPolicy:worker-policy
+ create aws.IamRole:worker
+ create aws.S3Bucket:assets
+ create aws.SqsQueue:jobs
Plan: 4 to add
```
The config declares no ordering, but the engine derives one:
```bash
atlantide graph infra.py --format mermaid
```
```mermaid
graph LR
subgraph cluster0["dev"]
n0["aws.IamPolicy:worker-policy"]
n1["aws.IamRole:worker"]
n2["aws.S3Bucket:assets"]
n3["aws.SqsQueue:jobs"]
end
n1 --> n0
n2 --> n0
n3 --> n0
```
The policy reads `worker.arn`, `assets.objects_arn` and `jobs.arn`. None of
those ARNs exists yet, so each read returns a lazy `Ref`, and each `Ref` becomes
a graph edge. The role, bucket and queue are independent and are created in
parallel; the policy waits for all three.
`allow()` builds the policy document, so no JSON is hand-written. Actions are
enum members such as `S3Bucket.Action.GetObject`, so a misspelled action fails
before anything runs.
The statement uses `assets.objects_arn` rather than `assets.arn`, because
object-level permissions require the `/*` form. The resource exposes both ARNs as
named attributes.
---
## 3. A Lambda, fingerprinted from disk
Create the handler:
```python title="processor/app.py"
def handler(event, context):
return {"received": len(event.get("Records", []))}
```
Add the function and give it the role:
```python
processor = LambdaFunction(
"processor",
role_arn=worker.arn,
handler="app.handler",
code_path="processor",
signing_secret=SecretRef("app/signing-key-dev"),
)
```
`code_path` points at a local directory. At config-evaluation time the directory
is zipped with sorted entries and fixed timestamps, then fingerprinted into
`code_sha256`. Two checkouts of the same source produce the same digest; editing
`app.py` changes it, and the next plan shows an update.
Plan:
```bash
atlantide plan infra.py --state infra.db
```
```
state: infra.db
error: undefined secret(s): dev:aws.LambdaFunction:processor.signing_secret ->
'app/signing-key-dev'
```
`SecretRef("app/signing-key-dev")` names a secret that is not in the store. An
apply would fail part-way through, after creating some resources, so Atlantide
checks every referenced secret during planning.
Set the secret:
```bash
atlantide secret set app/signing-key-dev
```
```
set secret 'app/signing-key-dev'
```
Without a value argument, the command prompts for it with hidden input. By
default, secrets are held in a local AES-256-GCM encrypted keyfile store;
`[secrets]` in `atlantide.toml` can point at SSM Parameter Store or the
environment instead.
```bash
atlantide plan infra.py --state infra.db
```
```
state: infra.db
dev ────────────────────────────────────────────────────────────────────────────
+ create aws.IamPolicy:worker-policy
+ create aws.IamRole:worker
+ create aws.LambdaFunction:processor
+ create aws.S3Bucket:assets
+ create aws.SqsQueue:jobs
Plan: 5 to add
```
The secret's value never appears in the config, the IR or the state file; only
its name does. The plaintext is resolved in memory at apply time. State keeps a
salted digest, which detects a rotated secret without storing the value.
---
## 4. A shared VPC, and a second environment
Put the VPC that both environments share in its own stack:
```python
with Stack("common", region=Region.EuNorth1, name_prefix="atlantide", tags={"env": "common"}):
network = Vpc("network", cidr_block="10.0.0.0/16")
vpc_id = output("vpc_id", network.vpc_id)
```
`output()` returns a typed handle. When you consume the variable instead of a
string such as `"common:vpc_id"`, a typo is an undefined-variable error at config
time instead of a mismatch found during planning.
Declare the two environments and what differs between them:
```python
from atlantide.core import Config, EnvSchema
class AppEnv(EnvSchema):
versioning: bool = False
config = Config(
AppEnv,
envs={
"dev": {
"region": Region.EuNorth1,
"name_prefix": "atlantide",
"tags": {"env": "dev"},
},
"prod": {
"region": Region.EuNorth1,
"name_prefix": "atlantide",
"tags": {"env": "prod"},
"versioning": True,
},
},
)
```
Wrap the environment body in a loop over the environments, and attach a security
group to the shared VPC:
```python
for env in config.envs():
with Stack(env.name, config=env):
...
edge = SecurityGroup("edge", group_name=f"edge-{env.name}", vpc_id=vpc_id)
```
`region`, `name_prefix` and `tags` are well-known keys of every environment, so
`config=env` supplies all three. `versioning` is a config-specific variable with
a default: `dev` uses the default and `prod` overrides it. The shape is an
`EnvSchema`, the one class Atlas-lang admits (annotated fields only), so your
editor completes `env.versioning` and a misspelling is a type error instead of a
plan-time error.
The `Config` is validated when it is constructed, so a missing or mistyped prod
value fails `atlantide validate` in CI instead of when prod is applied.
Because `common` is in the same config, the engine turns the output handle into
a dependency edge and applies `common` before `dev` and `prod`. `dev` and `prod`
are independent and apply in parallel. `common` sits *outside* the loop because
it is not per-environment, so narrowing a run to one environment keeps it in the
graph.
> **Running one environment**
>
> `atlantide plan --env prod` acts on `prod` alone. `dev` is out of scope, not
> undeclared: its recorded state is not diffed and is never planned for
> deletion. The plan reports this:
>
> ```
> envs: prod (of dev, prod) — dev is not planned and will not change
> ```
> **Two stacks, or two configs?**
>
> A stack applied by a *separate* config is referenced with
> `StackReference("common").output("vpc_id")`, which resolves from committed
> state instead of from an edge within the run. Use the handle when the stacks
> share a config, and `StackReference` when they do not.
---
## 5. Deterministic names, and a value decided at apply
Two more lines in the environment body:
```python
build = Id("build-id", byte_length=4)
bucket_name = f"atlantide-assets-{env.name}-{uuid5('atlantide-buckets', env.name)[:8]}"
assets = S3Bucket(
"assets",
bucket=bucket_name,
versioning=env.versioning,
tags={"build_id": build.result, "config_hash": sha256_hex(env.name)[:12]},
)
```
The two values are decided at different times:
| | When it is decided | Where it lives |
|---|---|---|
| `uuid5(...)`, `sha256_hex(...)` | **Config time.** Atlas-lang builtins — pure functions the interpreter injects, used with no import. | A fixed literal baked into the IR. |
| `random.Id` | **Apply time.** A *resource*, from the `random` provider. Generated once and pinned in state. | State. A re-apply is a NOOP, never a new value. |
The bucket name is stable across every run and machine; the build id is stable
across every run after the first.
The bucket reads `build.result`, so the engine orders the `random` resource
before the AWS bucket in a single graph spanning two providers.
`versioning=env.versioning` reads the value from the environment instead of
branching on its name. Per-environment differences are declared once, typed, in
the `Config`, instead of as `env == "prod"` tests in the body.
---
## 6. Policies, at the top of the file
```python
enforce("require-tags", keys=["env"])
enforce("deny-destroy-in-protected", stacks=["prod"])
```
Policies run at the end of planning against the finished changeset, so they see
the planned actions as well as the config. A mandatory violation blocks `apply`
before anything is changed.
To trigger the first policy, remove the `env` tag from the stacks and plan:
```
policy DENY require-tags: dev:aws.S3Bucket:assets is missing tag(s): env
policy DENY require-tags: dev:aws.SqsQueue:jobs is missing tag(s): env
policy DENY require-tags: dev:aws.SecurityGroup:edge is missing tag(s): env
policy DENY require-tags: dev:aws.LambdaFunction:processor is missing tag(s):
env
...
10 mandatory policy violation(s) block apply
```
Every offending resource is named. Stack tags merge into every resource in the
body, so restoring `tags={"env": env}` on the two stacks resolves all ten
violations.
The second policy, `deny-destroy-in-protected`, fails any plan that deletes or
replaces something in `prod`. This includes removing a resource from the file
and editing an `immutable()` field, since a replacement is a destroy followed by
a create.
> **It does not cover `atlantide destroy`**
>
> `destroy` runs against an empty config and therefore evaluates no policy
> bindings. `Lifecycle(prevent_destroy=True)` is the per-resource guard for
> that path.
---
## 7. The finished config
Add the exports for each environment:
```python
output("assets_arn", assets.arn)
output("assets_bucket", assets.bucket)
output("jobs_url", jobs.url)
output("worker_role_arn", worker.arn)
output("processor_arn", processor.arn)
output("build_id", build.result)
output("edge_sg_id", edge.group_id)
output("region", Region.EuNorth1)
```
```bash
atlantide plan infra.py --state infra.db
```
```
state: infra.db
common ─────────────────────────────────────────────────────────────────────────
+ create aws.Vpc:network
dev ────────────────────────────────────────────────────────────────────────────
+ create aws.IamPolicy:worker-policy
+ create aws.IamRole:worker
+ create aws.LambdaFunction:processor
+ create aws.S3Bucket:assets
+ create aws.SecurityGroup:edge
+ create aws.SqsQueue:jobs
+ create random.Id:build-id
prod ───────────────────────────────────────────────────────────────────────────
+ create aws.IamPolicy:worker-policy
+ create aws.IamRole:worker
+ create aws.LambdaFunction:processor
+ create aws.S3Bucket:assets
+ create aws.SecurityGroup:edge
+ create aws.SqsQueue:jobs
+ create random.Id:build-id
Plan: 15 to add
Outputs:
common:vpc_id = (known after apply)
dev:assets_arn = (known after apply)
dev:assets_bucket = 'atlantide-assets-dev-b89f6cc5'
dev:jobs_url = (known after apply)
dev:worker_role_arn = (known after apply)
dev:processor_arn = (known after apply)
dev:build_id = (known after apply)
dev:edge_sg_id = (known after apply)
dev:region = 'eu-north-1'
prod:assets_arn = (known after apply)
prod:assets_bucket = 'atlantide-assets-prod-445ca963'
...
```
`assets_bucket` and `region` already have values because both are computed at
config time. The other outputs read `(known after apply)`.
The full graph:
```mermaid
graph LR
subgraph cluster0["common"]
n0["aws.Vpc:network"]
end
subgraph cluster1["dev"]
n1["aws.IamPolicy:worker-policy"]
n2["aws.IamRole:worker"]
n3["aws.LambdaFunction:processor"]
n4["aws.S3Bucket:assets"]
n5["aws.SecurityGroup:edge"]
n6["aws.SqsQueue:jobs"]
n7["random.Id:build-id"]
end
subgraph cluster2["prod"]
n8["aws.IamPolicy:worker-policy"]
n9["aws.IamRole:worker"]
n10["aws.LambdaFunction:processor"]
n11["aws.S3Bucket:assets"]
n12["aws.SecurityGroup:edge"]
n13["aws.SqsQueue:jobs"]
n14["random.Id:build-id"]
end
n2 --> n1
n4 --> n1
n6 --> n1
n2 --> n3
n7 --> n4
n0 --> n5
n9 --> n8
n11 --> n8
n13 --> n8
n9 --> n10
n14 --> n11
n0 --> n12
```
One VPC feeds two independent environment subgraphs. The ordering follows from
which resources read which values.
---
## 8. Apply, and what happens after
```bash
atlantide secret set app/signing-key-prod # prod needs its own
atlantide apply infra.py --state infra.db
```
Apply takes a lease over the reachable graph, re-diffs under that lock, and
reconciles independent nodes in parallel. State is written per node as each one
succeeds, so a crash part-way through leaves an accurate record. On failure, the
default `--on-failure rollback` undoes the completed nodes.
A second run makes every node a no-op with no AWS API calls: the engine compares
hashes and stops.
After applying:
```bash
atlantide output --state infra.db # what the apply exported
atlantide state list --state infra.db # what is recorded
atlantide refresh --state infra.db # has anything drifted?
atlantide destroy --state infra.db # tear it down, dependents first
```
---
## Making it a project
Put the defaults in an `atlantide.toml` beside the config to omit the flags:
```toml
config = "infra.py"
state = "infra.db"
aws_region = "eu-north-1"
aws_profile = "default"
```
```bash
atlantide plan # no flags needed
```
For a team, move state to a shared backend (S3 with DynamoDB locking, or
Postgres) so concurrent runs cannot overwrite each other. See
[Remote state](https://atlantide-org.github.io/reference/remote-state.md).
A `[profile.]` overlay is separate from the `Config`: the `Config` defines
what each environment contains, and a profile defines where a run points (state
backend, AWS account, parallelism). They are selected independently with `--env`
and `--profile`. See [Configuration](https://atlantide-org.github.io/reference/configuration.md).
---
## Where to go next
- **[The Merkle hash](https://atlantide-org.github.io/how-it-works/merkle.md)**: why the second apply makes
no API calls, and which nodes a change propagates to.
- **[Authoring](https://atlantide-org.github.io/reference/authoring.md)**: output combinators for values not
known until apply, explicit ordering, and renaming a resource without
destroying it.
- **[Components](https://atlantide-org.github.io/reference/components.md)**: packaging this stack as a
reusable construct, publishable from a git repository and pinned by commit.
- **[Providers](https://atlantide-org.github.io/reference/providers.md)**: the full AWS resource list, and
how to write a provider.
- **A static website**:
[`examples/aws/example-three.py`](https://github.com/atlantide-org/atlantide/blob/main/examples/aws/example-three.py)
builds a private S3 origin behind CloudFront with an Origin Access Control, in
four resources.
# For agents
Every page on this site is also published as plain Markdown for coding agents,
along with a short guide for agents that edit a config.
## Give your agent the guide
[`SKILL.md`](https://atlantide-org.github.io/agents/SKILL.md) covers the
Atlas-lang restrictions, how dependencies and `Ref`s work, the `validate` →
`plan` loop, which commands are safe to run unasked, and what each error means. It is reproduced
[below](#the-guide).
**Claude Code**
Install it as a project skill; Claude Code loads it whenever a task touches
the config:
```bash
mkdir -p .claude/skills/atlantide
curl -fsSL https://atlantide-org.github.io/agents/SKILL.md \
-o .claude/skills/atlantide/SKILL.md
```
**AGENTS.md**
For Codex, Cursor, Copilot, Aider and anything else that reads `AGENTS.md`,
append the same guide without its frontmatter:
```bash
curl -fsSL https://atlantide-org.github.io/agents/AGENTS.md >> AGENTS.md
```
**Anything else**
Paste the guide into the system prompt, or point the agent at
`https://atlantide-org.github.io/llms.txt` and let it follow the links.
Commit the file with the project so every contributor's agent uses the same
rules.
## Machine-readable docs
| URL | What it is |
|---|---|
| [`/llms.txt`](https://atlantide-org.github.io/llms.txt) | An index of every page as Markdown, with a one-line description each, in the [llms.txt](https://llmstxt.org) format. |
| [`/llms-full.txt`](https://atlantide-org.github.io/llms-full.txt) | Every page in one file, in navigation order. |
| `/.md` | Any single page: replace the trailing `/` of its URL with `.md`, e.g. [`/reference/cli.md`](https://atlantide-org.github.io/reference/cli.md). |
| [`/agents/SKILL.md`](https://atlantide-org.github.io/agents/SKILL.md) | The guide, with Claude Code skill frontmatter. |
| [`/agents/AGENTS.md`](https://atlantide-org.github.io/agents/AGENTS.md) | The same guide, without it. |
The Markdown versions are generated from the same sources as these pages, with
admonitions turned into blockquotes, tabs into labelled blocks, and every link
made absolute. Each page also advertises its Markdown with
``, and the buttons at the top right
of every page view it or copy it to the clipboard.
## Let the CLI answer
The installed CLI reports the resource types and schemas available in that
install. The guide tells agents to query it:
```bash
atlantide resources # every resource type this install can see
atlantide schema aws.S3Bucket # one type's fields, mutability and defaults
atlantide validate --json # compile, with a machine-readable result
atlantide plan --json --detailed-exitcode
```
`--json` output and exit codes are covered under
[CI and automation](https://atlantide-org.github.io/reference/ci.md).
## The guide
````markdown
---
name: atlantide
description: Write, validate and plan Atlantide infrastructure configs (infra.py, atlantide.toml). Use when a project contains atlantide.toml or imports atlantide, or when asked to add, change or debug infrastructure declared with Atlantide.
---
# Atlantide
Atlantide is typed, deterministic Infrastructure-as-Code for Python. A config
(`infra.py` by default) is ordinary Python syntax executed by **Atlas-lang**, a
bounded interpreter with no clock, randomness, environment, network or
filesystem. Same config + same inputs = byte-identical plan.
Full docs as Markdown: https://atlantide-org.github.io/llms.txt
(`llms-full.txt` is everything in one file.)
## Safety
- `validate`, `plan`, `graph`, `resources`, `schema`, `providers`, `output` and
`state list`/`state show` are read-only. Run them freely.
- `apply`, `deploy`, `destroy`, `import`, `refresh --write`, `state rm`,
`state restore`, `state migrate` and `state unlock` change real infrastructure
or its record. **Do not run them unless the user asked for that specific
action.** Never pass `-y`/`--confirm` on their behalf.
- Never put a secret value in config, in `-var`, or in `atlantide.toml`. Use
`atlantide.secret("name")`; the user sets the value with
`atlantide secret set name`.
- Do not commit `atlantide.key`, `atlantide.secrets`, `atlantide.db*` or
`.atlantis/`. Do commit `atlantide.lock`.
## The loop
1. Find the project root: the nearest `atlantide.toml` going upwards. It names
the config file and state backend, so commands need no flags inside it.
2. Discover what exists — from the installed CLI, not from memory:
```bash
atlantide resources # every resource type this install can see
atlantide schema aws.S3Bucket # fields, types, mutability, defaults
```
3. Edit the config.
4. `atlantide validate --json` — subset check, graph, cycles. No credentials.
5. `atlantide plan --json --detailed-exitcode` — exit `0` no changes, `2`
changes pending, `1` error or policy denial.
6. Report the plan to the user. Pay attention to every `REPLACE` and `DELETE`:
`REPLACE` means an `immutable()` field changed and the resource will be
destroyed and recreated.
For the full traceback, put `--debug` before the command:
`atlantide --debug plan`. The same goes for the other global options,
`--profile/-P`, `--log-level`, `--audit-log` and `--no-plugins`.
## Atlas-lang: what config may not do
The subset is enforced before evaluation. Rejected:
| Construct | Use instead |
|---|---|
| `while` | bounded `for` |
| `class` | only a module-level `EnvSchema` subclass with annotated fields; components and resource types live in Python packages |
| `try` / `raise` / `assert` | guard with `if` |
| `match` | `if` / `elif` |
| `async` / `await`, `yield` | plain functions, comprehensions |
| `global` / `nonlocal`, `:=`, `del` | plain assignment |
| `import os`, `time`, `random`, … | only `atlantide.*` imports are allowed |
| `open`, `eval`, `exec`, `getattr`, `setattr`, `hasattr`, `globals`, `vars` | not available |
Allowed: functions, lambdas, `if`, `for`, comprehensions, f-strings, `with`.
Evaluation runs on a fuel budget; runaway recursion ends in
`FuelExhaustedError`.
Pure helpers are globals (no import): `slugify`, `uuid5`, `sha256_hex`,
`hmac_sha256_hex`, `b64encode`, `b64decode`, `to_json`, `from_json`, `merge`.
The `atlantide` global gives `atlantide.input(name, default)` and
`atlantide.secret(name)`.
## Shape of a config
```python
from atlantide.core import Config, EnvSchema, Lifecycle, Stack, concat, output
from atlantide.policy import enforce
from atlantide.providers.aws import S3Bucket, SqsQueue
enforce("require-tags", keys=["env"]) # top level, outside any Stack
enforce("deny-destroy-in-protected", stacks=["prod"])
class AppEnv(EnvSchema): # annotated fields only
versioning: bool = False
config = Config(AppEnv, envs={
"dev": {"region": "eu-north-1", "tags": {"env": "dev"}},
"prod": {"region": "eu-north-1", "tags": {"env": "prod"}, "versioning": True},
})
for env in config.envs():
with Stack(env.name, config=env, name_prefix="acme"):
assets = S3Bucket(
"assets",
versioning=env.versioning,
lifecycle=Lifecycle(prevent_destroy=True),
)
jobs = SqsQueue("jobs", fifo=True)
output("assets_arn", assets.arn)
output("assets_objects", concat(assets.arn, "/*"))
```
## Rules that matter
- **Dependencies come from reads.** `assets.arn` returns a lazy `Ref`, and
reading it creates the graph edge. Use `depends_on=[x]` only when the
dependency carries no value.
- **A `Ref` is not a string.** Build strings from one with `concat(...)`,
`interpolate("https://{}", ref)` or `join(sep, parts)` from `atlantide.core`.
`"x" + ref` fails at evaluation, but **an f-string such as `f"{ref}"`
validates and plans cleanly and writes the literal text
`Ref(node_id=..., attr=...)` into the resource.** Never put a `Ref` in an
f-string.
- **Logical names are identifiers**, unique per type within a stack. Node
ids are `{stack}:{type}:{name}`, e.g. `prod:aws.S3Bucket:assets`.
- **Renaming a resource destroys it** unless the new one carries
`lifecycle=Lifecycle(aliases=("old_name",))`.
- **Per-environment differences go in `Config`**, as typed `EnvSchema` fields —
not as branches on the stack name and not as inputs. `region`, `tags` and
`name_prefix` are implicit keys every environment may set.
- **Per-run values** (a build number, a fork prefix) are
`atlantide.input("name", default)` and arrive as strings.
- **`with region("us-east-1"):`** overrides the region for one block, e.g. an
ACM certificate for CloudFront.
- **Another config's stack:** `StackReference("network").output("vpc_id")`.
- **Data sources** such as `AwsCallerIdentity` and `AwsAvailabilityZones` read
account facts; use them instead of hardcoding zone letters or account ids.
- **`Subnet` requires `availability_zone`**; `LambdaFunction` requires
`code_path=` or `s3_bucket=` + `s3_key=`; `S3Bucket` needs
`force_destroy=True` to be deletable while non-empty.
## Commands
```bash
atlantide init [--template minimal|aws] # scaffold; minimal needs no credentials
atlantide validate # compile only
atlantide plan [--env prod] [-t ] # preview; --env and -t narrow the run
atlantide graph --format mermaid # dependency graph
atlantide output [name] # values exported by the last apply
atlantide state list | state show # what state records
atlantide refresh # read-only drift report
```
`--json` is supported by `plan`, `apply`, `validate`, `refresh`, `import`,
`init`, `output`, `providers`, `state list` and `state show`.
## Errors
| Error | Meaning and fix |
|---|---|
| `LanguageError` | Construct outside Atlas-lang; the message names the line and the replacement. |
| `FuelExhaustedError` | Runaway recursion or an oversized loop. |
| `IRError` | A field value is not JSON-shaped data. |
| `RegistryError` | Duplicate logical name, unknown type, missing provider, or unknown component alias. |
| `CycleError` | Two resources read from each other; pass a literal on one side. |
| `PolicyViolationError` | A mandatory policy failed; fix the resource (usually missing tags). |
| `PreventDestroyError` | The plan destroys a `prevent_destroy` resource; surface to the user, do not remove the guard yourself. |
| `LockError` | Another run holds the state lease; wait. |
| `PlanDriftError` | State changed after the plan was shown; re-plan. |
| `RollbackError`, `LeaseLostError` | State may not match reality; tell the user to run `atlantide refresh`. |
A plan showing changes nobody made means config or *state* changed — the diff
is against recorded inputs, not the live cloud. `refresh` compares with reality.
````
# Glossary
Terms used across these pages, in the order the pipeline meets them.
**Atlas-lang** — the bounded interpreter that executes a config. Python syntax,
without any construct that would let the result depend on anything other than
the config text and its declared inputs: no clock, randomness, environment,
network or filesystem. See
[what config may not do](https://atlantide-org.github.io/reference/authoring.md#what-config-may-not-do).
**Fuel** — the step budget for an evaluation. A runaway computation stops with
a `FuelExhaustedError` instead of hanging.
**Input** — a per-run value the config may branch on, supplied by `-var`,
`--var-file` or `[inputs]`. The determinism guarantee is over *(config, inputs,
selected environments)*, not config alone. Not for secrets.
**Config** — the checked-in environment matrix: every environment and what
differs between them, declared once as `Config(AppEnv, envs=)` and validated
on construction. An input, by contrast, is a per-run value from outside the
repository. See
[Environments](https://atlantide-org.github.io/reference/configuration.md#environments).
**`EnvSchema`** — the shape of one environment, declared as a class of annotated
fields. It is the only class Atlas-lang admits, because it is a declaration, not a
computation. It makes `env.domain` complete in an editor and `env.domian` a type
error. The untyped alternative is a mapping of `var()` declarations.
**Environment** — one entry in a `Config`. Its name is its stack's name, and
`--env` narrows a run to it. An environment left out is *out of scope*, not
undeclared: its state is not diffed and is never planned for deletion.
**Resource** — one managed thing, declared with a logical name. Its fields carry
their own mutability.
**Logical name** — the name you give a resource in config. **Node id** — what it
becomes once scoped: `{stack}:{type}:{name}`, e.g. `prod:aws.S3Bucket:assets`.
Most commands accept a short form or a glob in place of a full id.
**Stack** — a namespace scoping region, tags and name prefix over the resources
declared in its body.
**Component** — several resources behind one parameterized object. Written in
Python, used by config, never defined by it. The children become ordinary flat
resources named `{component}-{child}`.
**Ref** — the lazy handle returned by reading a computed field. The read creates
the dependency edge; the `Ref` resolves to a real value at apply.
**Mutability** — each field is `mutable()` (in-place `UPDATE`), `immutable()`
(`REPLACE`) or `computed()` (never diffs; produced by the provider).
**IR** — the intermediate representation: the content-hashed, provider-stamped
graph a config lowers to. Everything downstream works on the IR, not on the
source.
**Canonical form** — RFC 8785 JSON. Fixes key order, number formatting and
string escaping, so the same IR always serializes to the same bytes and the
content hash is stable.
**`input_hash`** — a node's Merkle hash: its own canonical inputs folded
together with its dependencies' hashes. Unchanged hash, no provider call. See
[The Merkle hash](https://atlantide-org.github.io/how-it-works/merkle.md).
**Symbolic ref** — the hash is computed over the *reference* to an upstream
value, not over the resolved value (which does not exist yet at plan time).
**Diff** — the comparison of desired `input_hash`es against those recorded in
state. It compares against *recorded* inputs, not the live world; comparing
against the live world is [`refresh`](https://atlantide-org.github.io/reference/cli.md#detecting-drift).
**Changeset** — the result: one action per node — `NOOP`, `CREATE`, `UPDATE`,
`REPLACE` or `DELETE`.
**Conditional replace** — a `REPLACE` caused by an immutable field holding a
`Ref`. It becomes a no-op if the value resolves unchanged at apply.
**Plan** — the ordered, guarded, policy-checked changeset.
**Policy** — a check over one node's pending change, evaluated at plan time.
`MANDATORY` blocks, `ADVISORY` warns. See [Policies](https://atlantide-org.github.io/reference/policies.md).
**Lease / lock** — the time-bounded claim a run takes on state before writing.
Renewed while the run lives; lapses if it dies.
**Fenced write** — a state write that succeeds only if the writer is still the
recorded lease holder. The store makes the check, not the writer, which prevents
two concurrent runs from silently merging state.
**Compensation** — the undo recorded for a completed node, run in reverse
completion order when an apply fails. See [When apply
fails](https://atlantide-org.github.io/how-it-works/failure.md).
**Poisoned row** — a state row whose `input_hash` is cleared so the next plan
cannot Merkle-skip it. Set before a compensation runs, so a rollback that fails
mid-way cannot leave a silent divergence.
**Plan drift** — the changeset about to execute differs from the one that was
approved, because state moved between the two. Raised, not reconciled.
**Drift** — the live world differing from what state records. Found by
`refresh`, and only in the fields a provider's `read` reports.
**Artifact** (`.atlas`) — a compiled, content-hashed config with provider
versions pinned. `deploy` applies it without re-executing the config, so identical
bytes are promoted between environments.
**Output** — a value exported with `output()`, namespaced by its stack and
persisted into state after apply. Read back with `atlantide output`, or from
another stack via `StackReference`.
**Secret** — a name, not a value. `atlantide.secret("x")` returns a `SecretRef`;
the plaintext is resolved in-memory at apply and never reaches the IR, the
artifact or state.