Walkthrough¶
A practical tour of the whole engine, requiring no credentials. Every command below was run, and every block of output is the output it produced.
The example uses the local provider, which writes files to disk, so it can be
followed without an AWS account. The pipeline it exercises is the same pipeline
the AWS provider uses; only the create, read, update and delete
implementations differ.
If you have not read the mental model yet, start there — this page assumes it.
1. Set up the example¶
mkdir demo && cd demo
demo/infra.py:
"""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)
Three things are happening in these eleven lines:
| Line | What it is |
|---|---|
with Stack("demo", region=...) |
A namespace. Every resource inside gets node id demo:<type>:<name>. 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), 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):
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
That declaration is the whole reason the engine knows a path change costs a recreate while a content change does not. The cost is visible in the type, before anything runs.
2. Validate — the pure front half, on its own¶
atlantide validate infra.py
ok infra.py — 2 resource(s), no cycles
No state file, no credentials and no network access. This is the check that belongs in a pre-commit hook: it runs stages 3 to 5 below and stops.
The sandbox is real, not a convention¶
# 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:
^
# 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 = ....
That last restriction is why 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
Four properties of this stage matter:
It is a tree-walking interpreter, not exec. There is no os module to reach
for, because the interpreter never binds one.
Evaluation is fuel-bounded: it has a step budget, so a config cannot hang the
engine. Together with the ban on while, this makes termination provable.
Every exception that is not already an AtlantideError is converted to a
LanguageError. A broken config produces a configuration error rather than a
stack trace out of the engine.
- Output: a ResourceRegistry (core/resource.py)
holding the resources, the declared outputs, the policy bindings and the inputs
the config actually 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):
canonical inputs, the dependency ids taken from resource.refs(), and the
provider name and version stamped in.
You can see the result directly — build writes the IR to a file (step 12):
{
"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
}
This is the central mechanism, visible in one screen. 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. A 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. That property is what makes the content hash
meaningful and the .atlas artifact portable.
To verify it, build twice and compare:
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
Byte-identical files, not just equal hashes.
6. Stage 4 — 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:
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): IR, graph,
hashes, resources, policy bindings, outputs, inputs.
The input_hash those last two lines produced is the idea everything after this
point depends on. The Merkle hash takes it apart in detail — the
definition, the ripple it causes, and the two cases it deliberately cannot see.
7. Stage 5+6 — Diff and Plan¶
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 begins as a hash comparison: 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 does differ,
_classify
consults the per-field mutability map to decide 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:
atlantide plan infra.py --state demo.db --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 gives CI the tri-state: 0 no changes, 2 changes
pending, 1 error.
8. Stage 7 — Apply¶
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
cat out/greeting.txt # hello atlantide
cat out/receipt.txt # 1caa462dd96fe06c4e4afa9476f5a2c59c39270f21a09e5a65602daee9def0e0
receipt's content is greeting's sha256. The Ref resolved at apply time,
once greeting had actually been 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
Five details of this stage are easy to miss.
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
makes the store refuse a write from a lease that is no longer the holder, so a
stalled run that later wakes up cannot corrupt state.
Apply re-diffs under the lock, because another run may have created a node in
the meantime. That means what executes could differ from what was approved, so the
approved changeset is passed in as expect and any difference raises
PlanDriftError rather than being silently substituted. --allow-plan-drift
opts out of the check.
Execution is a DAG scheduler,
run_graph.
Each node awaits its predecessors' asyncio.Event and only then takes the
concurrency semaphore. Awaiting dependencies before acquiring the semaphore is
what stops 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 incrementally, one
node at a time, so a crash part-way through the graph still leaves an accurate
record.
9. Re-apply — the Merkle skip¶
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 is the difference between a re-run costing nothing and one costing hundreds of API calls.
10. UPDATE vs REPLACE — where per-field mutability pays off¶
Change a mutable() field¶
Edit content to "hello world":
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
Look closely at the second node. receipt's own input did not change — the
$ref is identical on both sides — and yet it is an update.
This is Merkle propagation made visible. receipt's input_hash includes
greeting's hash, so a change to greeting forces receipt to be reconciled.
It has to be, because the value that reference resolves to is about to change.
The Merkle hash shows the two
hashes that differ and the single deps entry that caused it.
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 edit has the same shape but a different cost: ± replace rather than
~ update, decided entirely by the immutable() marker on the field. Note that
the downstream node follows in both cases.
11. Drift, and the honest limit of the model¶
Tamper with the file behind the engine's back:
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)
Two details in that output deserve attention.
1 of 2 inputs checked, and not checked: content. Drift is only visible in
fields that the provider's read actually reports.
LocalProvider.read
returns checksum and path, and never returns content. Atlantide reports that
limitation rather than claiming the field is in sync, because it will not assert
what it did not verify. Passing -v names the unchecked fields; without it you
get a one-line reminder.
state unchanged. refresh is read-only by default. Add --write to record
what it found back into state.
The next part is where the model differs from Terraform's, and it is worth following closely:
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 tampered file is not repaired. The diff compares desired inputs against
recorded inputs, and neither moved: content drifted but the provider cannot
report it, and checksum drifted but is computed(), so it never contributes to
a diff by design. Nothing in the input set changed, the hash is therefore
unchanged, and the node is a no-op.
Forcing the repair is explicit:
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.
The general rule: hash-based diffing buys free re-runs and exact propagation, and
the price is that out-of-band changes to fields a provider cannot read are
invisible to plan. refresh is how you look for them, and --replace is how
you force the repair.
12. Artifacts — build once, deploy anywhere¶
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).
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) rebuilds
live Resource objects from the IR → the same _apply_compiled path as apply.
This is what makes promotion safe: 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¶
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 │
└──────────────────────────┴────────────┴─────────┘
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¶
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)
Note the reversal: the plan lists the nodes in graph order, but execution runs
receipt before greeting, because deletes go in reverse topological order.
destroy is implemented as a diff against an empty desired graph
(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. Seesecrets/. - Policies —
enforce("require-tags", keys=["env"])at config top level; mandatory violations blockapply. See Policies. - Components (L2) — publishable from a git repo, pinned to commit + content
hash, vendored locally. Worked example in
examples/components/. - A real AWS graph —
examples/aws/example-one.py: per-env stacks, cross-stack outputs, IAM, Lambda withcode_pathfingerprinting,SecretRef, two enforced policies. - Writing a provider — a provider is an ordinary Python package exposing a
ProviderPluginon theatlantide.providersentry-point group. Thelocalprovider is 80 lines and is the shortest complete example:providers/local/. - When it goes wrong — When apply fails covers rollback and its limits; Troubleshooting indexes every error by the stage that raises it.
- Full reference — the CLI, Authoring, Configuration and Remote state pages.