Introduction
Aura is a configuration language that compiles to JSON, YAML or TOML. It is built around three promises:
- Deterministic by construction. Two runs produce byte-identical output. The
language has no
now(), no randomness and no implicit access to the environment — everything external (files, environment variables) arrives only through rights granted explicitly at the point of running it. - Supply-chain safety. An imported package physically cannot read your files or environment variables. The capability model is Deno’s idea, applied more strictly: even calling a package’s exported function runs with the package’s rights rather than yours.
- Validated before deploy. Typed schemas,
assertinvariants and static dead-code analysis mean configuration mistakes surface at build time instead of in production.
What it looks like
type Service
name: String
port: Int
end
base_port = 8000 # a private computation
is_prod = env("APP_ENV", "dev") == "production"
api: new Service # a property — this reaches the output
name: "api"
port: base_port + 1
end
replicas: is_prod ? 3 : 1
assert base_port > 1024, "privileged ports are not allowed"
aura eval app.aura --allow-env=APP_ENV
Who it is for
- DevOps and SRE: Kubernetes manifests, CI matrices, service configs — without
copy-pasted YAML, and schema-checked before
kubectl apply. - Platform teams: publishable packages of standards (
pub def/pub type), versioned and locked by integrity hash. - Application developers: one
.auradeclaration becomes JSON, YAML or TOML for any consumer; in Rust it embeds directly as a library.
Where things are
- This book is the tutorial and reference for using the language.
- SPEC.md is the formal specification, for anyone implementing it.
- examples/ holds working examples with their expected output.
This book is also available in Russian. (The path is relative to the book’s root: the Russian translation is published under
/book/ru/.)
Quick start
Installing
The fastest way to try the language is to install nothing: the playground runs the real compiler in your browser.
For a binary:
cargo install aura-lang
Or download a build for your platform from
Releases — Linux (gnu and
static musl), macOS (Intel and Apple silicon) and Windows, each with a .sha256.
On x86_64 Linux prefer the musl build: it is static, so it has no glibc floor.
In GitHub Actions:
- uses: aura-config/setup-aura@v1
- run: aura check deploy.aura --strict
To build from source instead:
git clone https://github.com/aura-config/aura-lang && cd aura-lang
cargo build --release
# the binary: target/release/aura
Your first manifest
Create hello.aura:
app_name = "hello"
port = 8080
service:
name: app_name
url: "http://localhost:#{port}"
replicas: 2
end
$ aura eval hello.aura
{
"service": {
"name": "hello",
"url": "http://localhost:8080",
"replicas": 2
}
}
Notice that app_name and port — declared with = — did not reach the
JSON: they are private computations. Only properties (name:) and blocks are
exported. The next chapter goes into that.
The three commands you need daily
aura eval app.aura # evaluate → JSON on stdout
aura check app.aura --strict # check without evaluating (a linter; for CI)
aura fmt app.aura # canonicalise the layout
Structure without the pain
Aura has no significant indentation — this is where YAML scar tissue heals.
Structure comes from line breaks and an explicit end:
server:
http:
port: 8080
timeouts:
read: "30s".parse_duration()
end
end
end
Indentation is for humans only. aura fmt canonicalises it automatically and is
guaranteed not to change meaning: the token stream before and after is identical.
Computing and exporting: = versus :
The one rule that sets Aura apart from YAML and JSON — the same distinction as locals versus outputs in Terraform:
| Form | Meaning | In the output? |
|---|---|---|
x = expr | a private variable | no |
key: expr | a property | yes |
domain "name" ... end | a block | yes (the key is the name) |
base = 8000 # a computation
port: base + 80 # output: {"port": 8080}
This is what makes dead-code analysis exact: an unused = variable is always
genuine litter (W0501), never “perhaps someone’s output”.
Immutability
Assigning twice in the same scope is an error:
x = 1
x = 2 # E0301: variable 'x' is immutable
Shadowing is explicit
Redefining an outer variable inside a nested block requires saying so:
log_path = "/var/log/app.log"
domain "debug"
log_path = "/tmp/debug.log" # E0302: shadow is required
shadow log_path = "/tmp/debug.log" # fine — the intent shows up in the diff
path: log_path # → "/tmp/debug.log"
end
“Why is the path different in production?” stops being a silent surprise:
shadowing is always visible as the word shadow.
Long numbers
Group the digits with _ where it helps the eye:
max_bytes: 10_000_000
retention: 2_592_000
port: 8_080
rate: 0.000_001 # in a fraction the groups run away from the point
It is entirely optional: 2371933 and 2_371_933 are the same number, and
nothing asks you to group anything.
When you do, the separator has to group by threes, counted away from the
decimal point. The short group therefore sits at the front of an integer and
at the end of a fraction: 8_080, 1.123_45.
That is stricter than Go, Ruby or Rust, where 2_3_7_1_9_3_3 is legal. Two
reasons. A separator free to sit anywhere is a second way to write one number,
and this language spends its rules removing those. And it misleads: 1_0000_000
is ten million, but anyone scanning for threes reads something far larger.
100_, 1__0 and 1_.5 are errors too — there is nothing to separate.
This exists because configuration is where long numbers actually live, and a miscounted zero is the mistake nothing else catches — the value is valid, the type is right, and the service quietly gets ten times what it should.
Strings and interpolation
name = "auth"
image: "company/#{name}:v#{1 + 1}" # → "company/auth:v2"
Inside #{...} the ordinary expression syntax applies, and quotes in nested
strings need no escaping — "#{list.join(", ")}" is valid.
A null has no text form, so interpolating one is E0324 rather than the word
null:
host = env("HOST", null)
url: "https://#{host == null ? "localhost" : host}/api"
The guard is spelled out because which text stands in for a missing value is a
decision about the configuration. Printing null would put those four letters
into the output, where whatever reads it next sees a setting rather than a
missing one — and the usual reason a null reaches a string is a value that got
lost upstream.
Reaching into data
A dot is for fields; brackets are only for list indices:
version: cfg.package.version # ordinary keys
special: cfg."key with a space" # any key, as a string
dynamic: cfg.envs."#{region}" # a computed key
first: apps[0].name # an index (out of range is E0317)
optional: cfg.get("maybe", "default") # a miss returns the default, not an error
A typo in a field name is error E0308 with an exact position, not a silent
null.
Blocks and scopes
Configuration is rarely flat. Aura has two forms of nesting, and they differ not in what they output but in what you are allowed to write inside.
| Form | What goes inside | In JSON |
|---|---|---|
key: … end | properties only — data | a nested object |
domain "name" … end | properties and computations (=, shadow, assert, def) | an object under the label as key |
An object literal: just data
security:
tls: true
min_version: "1.3"
end
{ "security": { "tls": true, "min_version": "1.3" } }
Only properties are allowed inside. If you need computations, use domain.
On one line
A short object fits on one line: the same block, with commas where the newlines were (D38).
resources:
requests: cpu: "250m", memory: "512Mi" end
limits: cpu: "500m", memory: "1Gi" end
end
A comma separates two fields and nothing else - one missing, or one before
end, is E0205, and the message says which. aura fmt keeps whichever form
you wrote. Two ends on a line (a: b: 1 end end) are legal and hard to read:
write the outer level as a block.
Inside a list the one-line form needs a schema, new Port name: "http", port: 80 end - there the comma also separates the elements, and the schema checks the
fields besides.
Bound with =
= binds an object exactly as : outputs one (D10): the block is the same, and
it stays out of the result.
defaults =
port: 8080
timeout: "30s"
end
limits = cpu: "500m", memory: "1Gi" end
server:
port: defaults.port
timeout: defaults.timeout
end
{ "server": { "port": 8080, "timeout": "30s" } }
Change defaults = to defaults: and the object is output as well; nothing
else about it changes. If an = block is left without its end, the error
points at the line with =.
domain — a section with its own scope
The label becomes the key, and the body is a full scope: =, shadow and
assert all work.
domain "prod"
replicas: 3
region: "eu-central"
end
{ "prod": { "replicas": 3, "region": "eu-central" } }
The label can be any expression, not only a string literal.
Scope and shadow
Outer variables are visible, but overriding one requires an explicit shadow
(D7):
port = 80
domain "prod"
shadow port = 443 # without `shadow` this is E0302
listen: port # 443
end
fallback: port # 80 — nothing outside changed
This is the direct answer to “why is the port different in production”: the override is visible in the code.
Lists of objects: map, with no extra keywords
A lambda body is a scope too (D17), so list elements are described directly, private computations included:
services: ["api", "web"].map (n, i) ->
base = 8000 # private: never reaches the JSON
name: n
port: base + i
end
{
"services": [
{ "name": "api", "port": 8000 },
{ "name": "web", "port": 8001 }
]
}
The name field here is an ordinary property that you write yourself. A component
keyword used to insert that line implicitly; D17 removed it, because the language
should not add fields behind your back.
Which to use
- Data only →
key:…end. - Data plus computation →
domain. - A list of objects →
mapwith a lambda body; no block needed.
The general rule: code bodies (def, a lambda, domain) are scopes; an
object literal is data.
Schemas and validation
Schemas
type declares a schema; new instantiates one with checking:
type Container
name: String
image: String
port: Int
end
main: new Container
name: "gateway"
image: "company/gateway:2.4.1"
port: 8080
end
A short instance fits on one line, with commas between the fields (D38). It is checked exactly as the block form:
sidecar: new Container name: "proxy", image: "envoy:1.30", port: 9901 end
What is checked at instantiation:
| Situation | Code | When |
|---|---|---|
| A schema field is missing | E0511 | always |
Wrong type (Int vs String, Int vs Float) | E0512 | always |
| An extra field the schema does not declare | E0513 | --strict only |
A default is checked where the schema is declared, not only where it is
used. q: Int = "not an int" is E0516 at the type, because a field that
callers always supply explicitly would otherwise never reach new with its
default, and the declaration would go on claiming something impossible.
Only literal defaults are judged — what an arbitrary expression evaluates to is
not knowable before it runs, and new still checks the result.
type Quota
# `Int? = null` — the marker admits null, the default makes the field
# optional. The marker alone would still require the field at `new`.
limit: Int? = null
end
Field types are String, Int, Float, Bool, List and Object. Int and
Float are separate types, so memory limits in bytes and 64-bit identifiers keep
their precision — overflow is E0304, not a silent wrap.
Fields that may have no value
A field holds a value of its type and nothing else. When a value is genuinely
absent — an unlimited quota, a deadline nobody set — mark the field with ?:
type Plan
id: String
quota: Int?
end
unlimited: new Plan
id: "enterprise"
quota: null
end
This exists to kill the sentinel. Without it, “unlimited” gets written as -1
or 2147483647, and everyone has to remember which number means what and where.
A sentinel is a valid value of its type, so nothing ever catches a wrong one.
? answers one question only: may the value be absent. Whether the field can be
left out is the separate job of = default, and the two compose:
| Declaration | Omitting it | Writing null |
|---|---|---|
quota: Int | E0511 | E0512 |
quota: Int? | E0511 | allowed |
quota: Int = 100 | gives 100 | E0512 |
quota: Int? = null | gives null | allowed |
A nullable field is still type-checked when it does hold a value, and
aura types emits Option<i64>, number | null and *int64.
The one way ? can cost you
? widens what a field accepts, and that is exactly what makes it worth a
second look when the value comes from outside:
quota: data.get("quota") # no fallback
Without ? a missing key is E0512 and the run stops, so two people with
different input files find out immediately. With ? the same difference passes
as null, and they ship different configurations without a word.
That combination is W0513, and under --strict it is an error:
[W0513] Warning: 'quota' is nullable and is filled from `get(key)`, so a
missing value becomes null instead of an error
Help: name the value that stands for absence: `get(key, fallback)`
— or drop the `?` so a missing one is reported
env(name) without a fallback is the same case. Naming the fallback is all the
warning asks for: data.get("quota", 0) says which value means absence, and
says it in the manifest rather than in someone’s head.
Lists with an element type
List on its own accepts any elements. Write [T] to say what is in it:
type Service
name: String
tags: [String]
endpoints: [Endpoint]
end
The type mirrors the value: [1, 2] is a list, so [Int] is its type. Nesting
follows with no new rule, [[Int]], and an enum works as the element type.
A wrong element is reported with its index, because “one of these is wrong” is no help in a list of forty:
[E0512] Error: endpoints[0] of schema Service expects Endpoint, got String
This also reaches the host. aura types emits Vec<Endpoint>, Endpoint[] or
[]Endpoint instead of an untyped array.
Bare List keeps its old meaning, so nothing written before this needs changing.
A field typed by a schema holds what new would accept
A field typed Port, [Port] or [[Port]] holds values that new Port would
accept - wherever they came from (D41). An element read from JSON, YAML or a
host is checked by the same rules as one written with new: required fields,
types, enums and invariants.
type Port
number: Int
protocol: String = "tcp"
assert number > 0, "port must be positive"
end
type Service
ports: [Port]
end
raw = read_file("ports.json").parse_json()
svc: new Service ports: raw end
A wrong value names the way down to it:
[E0515] Error: Port: port must be positive (in ports[2])
[E0511] Error: missing field 'port' required by schema Endpoint (in routes[1].target)
How it behaves, and why
- Structural, not nominal. A value carries no mark of the schema that made
it, so the question is “would
new Portaccept this?”, not “was this made bynew Port?”. Data from outside passes when it is right, without being rebuilt field by field through.map. - Defaults are filled in.
{"number": 80}in a[Port]field becomes{"number": 80, "protocol": "tcp"}, in the schema’s field order, exactly asnewwould make it. One schema gives one shape of output, whatever the source - which is whataura typesand a host reading the result rely on. - An instance made by
newis checked again. The checks are pure and the defaults are already there, so nothing changes; the alternative, a hidden mark saying “already checked”, would be state a reader cannot see. - Extra fields follow
new: kept, andE0513under--strict. - A schema may hold itself -
children: [Node]- to any depth up to 32 levels; deeper isE0208rather than a crash, since data, unlike code, can be built to any depth.
Before D41 such a field took any object at all: one without number, or one
whose number broke the invariant. A manifest that relied on that now stops
with an error that says which element is wrong.
Rules the schema states about itself
A type says what shape the values have. It can also say what they must be:
type Plan
price_monthly: Int
price_yearly: Int
max_channels: Int
assert price_monthly >= 0, "price must not be negative"
assert max_channels > 0, "a plan needs at least one channel"
assert price_yearly <= price_monthly * 12, "yearly must not exceed twelve months"
end
These run on every new Plan, and a failure is E0515 carrying the message the
schema wrote:
[E0515] Error: Plan: yearly must not exceed twelve months
The third one is the reason this is worth having: it relates two fields, which no per-field constraint can express. It needs no ordering rule either, because every field is filled and type-checked before any invariant runs.
Why not just write the assert next to the new
Because then it is not part of the type. It does not travel with the schema, so an importer gets the fields without the rules; tooling cannot see it; and the next person to instantiate the schema simply will not write it.
Put it in the schema and it holds everywhere, including in a manifest whose author never read your package:
import "./lib.aura" as lib
p: new lib.Port
number: 80 # E0515: Port: ports below 1025 are privileged
end
What an invariant can see
Its own fields, and nothing else. Not the module the schema was declared in, not
the one the new was written in:
[E0504] Error: use of undefined variable 'limit'
Help: an invariant sees only this schema's fields (q), so it means the
same in every manifest that uses the schema; pass anything else
in as a field
That is deliberate. A rule that could read a module variable would mean something different in each manifest that imported the schema — which is the problem invariants exist to solve. It also means nothing effectful is reachable from one, so an instance is valid or invalid identically for everyone.
assert
Arbitrary invariants are the assert statement:
admins = [187650342, 244179081]
assert admins.uniq().len() == admins.len(), "Duplicate admin ids"
assert admins.len() >= 1
A failure is E0530, with your message and an exact position. assert also runs
under --dry-run: a rehearsal has to be able to find a validation failure.
fail inside an expression
For validation in the middle of an expression there is fail:
port: p > 0 && p < 65536 ? p : fail("invalid port #{p}")
Closed sets: enum
A String field accepts any string, so a typo in a value rides all the way to
production. enum declares a closed set of permitted strings:
enum Tier
"frontend"
"backend"
end
type Service
tier: Tier
end
svc: new Service
tier: "backand" # E0514, with "did you mean \"backend\"?"
end
An enum member is an ordinary string: the JSON contains "backend" and no
wrapper appears. It constrains the value; it does not introduce a new data type.
pub enum is exported to importers (D12). Members resolve where the schema is
declared, so an imported schema is validated against its own module’s enum —
even if you have one of the same name in scope.
Static analysis
aura check — and automatically before every eval — finds problems before
anything runs:
E0504— use of an undeclared variable, including inside#{...};W0501/W0502/W0503— unused variables, imports, functions and types;W0512— an effectful call (env,read_file) in an imported module.
Under --strict warnings become errors. That is the mode for CI.
Functions and methods
def — constructor functions
def returns an object when its body is properties (and a value when it ends
in one - below):
def make_env(name, replicas, debug)
replicas: replicas
log_level: debug ? "debug" : "warn"
db_url: "postgres://db.#{name}.internal:5432/app"
end
environments:
dev: make_env("dev", 1, true)
prod: make_env("prod", 6, false)
end
One def instead of three copy-pasted YAML files: this is the language’s main
anti-duplication tool.
A function that returns a value
A function whose body ends in an expression returns that expression (D40).
Bindings and assert may come before it, so a step can be named:
def ports_of(services)
all = services.map (s, i) -> s.port end
assert all.len() > 0, "no services"
all.uniq().sort()
end
ports: ports_of(catalogue)
A multi-line lambda follows the same rule; a one-line lambda,
(x) -> x * 2 end, already was an expression.
Three cases, and why each is as it is
- Properties, or a last value - not both. A body with
key:properties that also ends in an expression isE0215. Returning the value would drop the properties, returning the properties would drop the value, and either loses something without a trace. Make the value a property (value: ...) or remove the properties. - A value nobody uses is an error. A bare expression anywhere else - at
the top of a file, in a
domain, before the last line of a function - isE0214. Aura has no side effects, so a value that goes nowhere did nothing: the author almost always meantkey:(output it),name =(use it later) orassert(check it), and the help names all three. Before D40 such a line was evaluated and silently dropped. - Only bindings and checks is still an empty object. A body of
=andassertalone returns{}, as before: nothing in it was meant to be the result, so nothing is thrown away.
Both errors come from aura check, so the editor shows them as you type.
Lambdas
double = (x) -> x * 2 end
up: ["a", "b"].map (s, i) -> s.upper() end # a trailing lambda
A map or filter callback receives the element and its index; parameters you
do not need can be left out.
Methods
Called with a dot, and they chain:
active: services.compact().uniq().map (s, i) -> s.upper() end
The full list is in the method reference. The ones you will reach for:
- lists:
mapfiltercompact(dropnull)uniqfirstlastjoin(sep)contains(x)get(i, default) - objects:
merge(the right side wins)keysvaluescontains(key)get(key, default) - strings:
upperlowerlencontains(sub), plus the format parsers
The ternary operator
The language’s only branching construct:
mode: is_prod ? "webhook" : "long_polling"
The condition must be a Bool. Aura has no notion of truthiness, and anything
else is E0306.
Modules and packages
Imports
import "templates/defaults.aura" as defaults # a file, relative to this one
import github/acme/aura-k8s@v1.2 as k8s # a registry package; the version is mandatory
Cyclic imports are reported with the whole chain:
E0401: cyclic import: a.aura -> b.aura -> a.aura.
What a module exports
A module hands the importer an object: its properties, its blocks, and its pub items.
# validators.aura
pub type Service # the schema is part of the API
name: String
port: Int
end
def valid_port(p) # a private helper: invisible to importers
ok: p > 0 && p < 65536
end
pub def service(name, port) # the function is part of the API
name: name
port: valid_port(port).ok ? port : fail("invalid port #{port}")
end
# using it
import "validators.aura" as v
api: v.service("api", 8080)
worker: new v.Service
name: "worker"
port: 9000
end
The key safety guarantee: an exported function runs with its own module’s rights, not the caller’s. A package cannot borrow your permission to read files by getting you to call its function.
Installing packages: aura add
aura add github/acme/aura-k8s@v1.2.3 # from the network (an exact version)
aura add pkg/internal@v1.0.0 --from ./pkg.aura # from a local file
aura add is the only place Aura touches the network: the package is
downloaded, validated, placed in the local cache (~/.aura/registry) and recorded
in aura.lock with an integrity hash. eval always works offline.
aura.lock and –frozen
- the lock stores the exact version and integrity hash of every package;
- the hash covers the module’s token stream, not its bytes: reformatting it or fixing a typo in a comment does not trip the check, while renaming a field or changing a number does. An integrity check that fires on harmless edits teaches people to refresh the lock without reading it, which defeats the purpose;
- a
sha256-entry from an older version still verifies, and is upgraded the next time the lock is writable; - if a package’s contents change underneath it, that is
E0402 integrity mismatch; - in CI, run with
--frozen: resolution goes strictly through the lock, a missing entry isE0403, and the lock is never rewritten.
Version ranges (@v1, @v1.2) resolve against the local cache to the highest
match — v1.2 selects 1.2.*.
Time without surprises
now() does not exist
Calling now() or timestamp() is error E0533. This is not an omission but a
design decision (D13): the current time would make a config irreproducible,
breaking --dry-run comparisons, caching, and the whole idea that two runs give
the same result.
If you genuinely need the build time, the host passes it in:
built_at: env("BUILD_TIME", "unknown")
BUILD_TIME=$(date -u +%FT%TZ) aura eval app.aura --allow-env=BUILD_TIME
The decision about time is made and recorded by whoever runs the tool, and the config stays a pure function of its inputs.
Durations
Timeouts and TTLs are strings with d / h / m / s units, parsed into
seconds as an Int:
ttl = "1h30m".parse_duration() # → 5400
cache:
ttl_seconds: ttl
refresh_every: (ttl / 3).format_duration() # → "30m"
end
From there it is ordinary integer arithmetic: adding, subtracting, converting a
day into hours. A malformed string is E0319.
Dates
RFC 3339 ↔ epoch seconds, in UTC:
start = "2026-07-18T22:00:00Z".parse_datetime() # → an epoch Int
window:
start: start
end: (start + "4h".parse_duration()).format_datetime()
# → "2026-07-19T02:00:00Z" — crossing midnight works correctly
end
Offsets are supported (+02:00 denotes the same instant), as is the short date
form ("2026-07-18" is midnight UTC). A malformed date is E0320.
Higher-level calendar arithmetic — “plus three months”, working days, time zones — is deliberately outside the core. That is territory for community packages built on top of these epoch primitives.
The security model
Aura assumes configuration gets assembled out of other people’s code — packages, a colleague’s templates, snippets copied from somewhere — and builds its defences by construction rather than by agreement.
The capability model
By default a manifest can do nothing: it can read neither files nor environment variables. Rights are granted by flags at the point of running it, and they do not leak.
| Flag | Permits |
|---|---|
--allow-read=<dir> | read_file() inside that directory; paths are canonicalised, and .. cannot escape (E0311). A relative path is read from the manifest’s own folder (D37) |
--allow-env[=A,B] | env() for the named variables — with no list, all of them |
--hermetic | no I/O at all: env() and read_file() are E0505 in every module; refused together with --allow-* |
--allow-imports-io | extend the root’s rights to imported modules |
A call without the right is E0310, with a hint naming the flag to add.
Hermetic mode
--hermetic is the opposite end of the same dial: instead of granting rights, it
declares that none exist. env() and read_file() become E0505 wherever they
appear — root or import — and the flag cannot be combined with any --allow-*.
The distinction from simply omitting the grants is when you find out. A denied
call is E0310 at runtime, on the branch that happened to execute; E0505 is an
analysis error, so aura check --hermetic proves the property for the whole
manifest without evaluating it. That makes it usable as a gate:
$ aura check --hermetic manifests/*.aura
CUE, KCL and Starlark are hermetic by construction. Aura makes it a mode, so one repository can hold both the manifests that must read the environment and the manifests that must not, and CI can require the strict kind per directory.
Bounded evaluation
Hermetic proves a manifest cannot take anything. It does not prove the manifest
will finish: three nested range(1000).map fit in 95 bytes, pass
check --hermetic, and would run for over a minute. Every evaluation therefore
has a budget — the CLI, eval_file, the playground and Script alike:
| Budget | Exhausted | Default | Flag |
|---|---|---|---|
| steps — expressions evaluated | E0325 | 10 million | --max-steps=N |
| bytes — every value and frame created | E0326 | 256 MiB | --max-memory=MB |
Both are counted, never timed, so the same manifest stops at the same point on
every machine. The defaults are about thirty thousand times what the heaviest
example in the repository spends; --stats prints what a manifest spends, so a
budget can be set from a measurement.
Depth has a limit of its own, because steps do not bound it: a function calling
itself a few hundred times spends few steps and a great deal of stack. A single
expression nests at most 256 levels - a long chain such as a + b + c + ...
counts its length - and evaluation nests at most 256 levels at run time, a
recursive call counting the expressions it passes through. Past either is an
error, E0208 or E0399, never a stack overflow (D42). Like the budget, the
limit is a count, so it stops a manifest at the same point everywhere. A release
build fits it in 512 KiB of stack, so a host may call a Script from any thread
with at least that much - an iOS secondary thread has exactly 512 KiB.
Values have the same limit (D43). A loop can build a deep value without any
nesting of its own - reduce wrapping its accumulator adds a level a turn - and
every walk over a value, from writing it out to freeing it, recurses once per
level. So a list or object deeper than 256 is E0328 where it is built, and a
value from a host deeper than that is refused at the boundary. Parsed JSON and
YAML already stop at 128.
Import isolation
Rights belong to the root manifest. An imported module:
- cannot call
env()orread_file()— not even when the root holds those rights; - does not acquire rights through its own exported functions: the body of a
pub defruns with the capabilities of the module it came from, not the caller’s; - is flagged statically with warning
W0512if it contains effectful calls.
$ aura eval main.aura --allow-read=.
[E0310] Error: imported module has no capability to call read_file()
╭─[ evil_dependency.aura:2:7 ]
There is a runnable version of this in examples/security_demo/.
Host functions and reload
An application that embeds Aura can give a script its own functions (D35) and reload it while running (D36). Both are held to the model above:
- A host function is a grant, named by the application, one function at a time.
pureones - the host’s promise of determinism - keep a script hermetic;effectones are grants like--allow-env. - They reach the root module only. An imported package cannot call one unless
the application grants that function to imports by name (
E0310). - Every use is checked at load, before anything runs: a function not provided is
E0007. A host function cannot call back into the script (E0010). - A reload is a full load with the first load’s grants and its
aura.lock, frozen: an edited script cannot widen its rights, pull in an unpinned package, or drop an export the application bound (E0011). A file still being written is not loaded until it has been left alone for the settle period.
Supply chain
- A package’s version is mandatory in the import itself:
import github/acme/pkg@v1.2 as p. aura.lockpins the exact version and an integrity hash of the module’s token stream; a substitution isE0402. Hashing tokens rather than bytes means a reformat or an edited comment does not fire the check, while any change that can alter behaviour does. A lock written by an older version holds asha256-byte hash; it still verifies, and is upgraded in place on the next run that is not--frozen.--frozen(the CI mode) forbids resolving outside the lock (E0403).- The network exists only in
aura add; evaluation is always offline.
Determinism
now()does not exist (E0533) — the host passes time in throughenv.- Output key order is declaration order; two runs are byte-identical.
--dry-runperforms the whole evaluation but writes neither JSON nor the lock, and reports every read:[dry-run] read: ./Cargo.toml.
Formats: TOML, JSON, YAML
Reading
The three parsers are string methods, and they return ordinary Aura objects and lists:
cargo = read_file("./Cargo.toml").parse_toml()
team = read_file("./team.json").parse_json()
lint = read_file("./.rules.yaml").parse_yaml()
service:
name: cargo.package.name
owner: team.teams[0].lead
end
Integers from every format arrive as Int, with no loss of precision. A parse
failure is E0314, carrying the underlying library’s message.
Writing
From the CLI, with a flag:
aura eval app.aura --format json # the default, pretty-printed
aura eval app.aura --format json-flat # flattened keys: a.b.c = 1
aura eval app.aura --format yaml
aura eval app.aura --format toml # requires an object at the top level
From inside the language, with methods. Each format has two of them, and the choice is about who reads the result:
configmap:
# Read by a program: the compact form, because the newlines would only be
# escaped on the way in.
"app-config.yaml": settings.to_yaml()
# Read by a person, in `kubectl get cm -o yaml` or in a diff: spelled out.
"reference.yaml": settings.to_yaml_pretty()
end
to_json / to_yaml / to_toml give the most compact form the format allows;
to_json_pretty / to_yaml_pretty / to_toml_pretty give the form a person
reads. Compact YAML is flow style — {a: 1, xs: [1, 2]} — which is ordinary
YAML, not a dialect. Compact TOML keeps arrays on one line.
The CLI is the other way round: --format always writes the spelled-out form,
because it is producing a document rather than a string that goes inside one.
TOML’s limitations become honest E0603 errors: it has no null, and it requires
an object at the top level.
json-flat joins nested keys with a dot, which stays unambiguous only while no key
contains one. Keys written in Aura are identifiers, so they cannot — but
parse_json and friends return whatever the input held, and then {"a": {"b": 1}}
and {"a.b": 2} both want the key a.b. That is E0604 rather than one value
quietly overwriting the other. An empty object flattens to itself (a.b: {}); it is
a leaf, exactly as an empty list is.
Aura as a converter
Because the language reads and writes all three formats, a migration is one line:
# convert.aura
config: read_file("./legacy.toml").parse_toml()
aura eval convert.aura --allow-read=. --format yaml
Unlike yq or jq, schemas, assert and merging several sources are available
along the way — conversion with guarantees.
Embedding, and calling from other languages
Rust: as a library
[dependencies]
# `default-features = false` drops the `cli` feature, and with it clap, ariadne
# and ureq — which the library never uses. Measured on an empty project: 38
# packages instead of 96. The feature is on by default only so that
# `cargo install aura-lang` produces the `aura` binary.
aura-lang = { version = "0.4", default-features = false }
#![allow(unused)]
fn main() {
use aura_lang::facade::{eval_file, EvalOptions};
let opts = EvalOptions {
strict: true,
allow_read: vec!["config/".into()],
..Default::default()
};
let out = eval_file("config/app.aura".as_ref(), &opts)?;
let cfg: MyConfig = serde_json::from_value(out.json)?; // straight into your own structs
for w in &out.warnings {
log::warn!("{w}"); // Display: warning[W..]: ... at file:line:col
}
if let Some(lock) = out.updated_lockfile {
std::fs::write("config/aura.lock", lock)?; // writing it is the host's decision
}
}
Rights are set by the host application — the config never gets more than you
granted. Diagnostics arrive structured (code, severity, file, line,
column, help), so you can render them into your own log however you like.
A manifest that exists only in memory — a test harness, a browser, an editor
holding unsaved buffers — goes through eval_source instead, which takes a map of
name to text and resolves imports inside it.
In an async context, wrap eval_file in spawn_blocking: it reads files and
evaluates synchronously.
Reading a whole manifest into your types
When the manifest is the configuration, read all of it at once, straight into
your own structs - no JSON in between, and the same result aura eval prints:
#![allow(unused)]
fn main() {
#[derive(serde::Deserialize)]
struct Device { board: Board, sensors: Vec<Sensor>, /* ... */ }
let device: Device = Script::builder()
.file("device.aura")
.allow_read("config") // the files it reads, beside it
.env_values([("APP_ENV", "production")]) // exactly these, and no others
.load()?
.output()?;
}
env_values grants the named variables with the given values; env() reads
them from there and never from the process, so the service’s own environment
cannot leak into the manifest, and a test can give it any environment without
touching the process’s. aura types --lang rust writes the structs for the
schemas a manifest declares. Every example in the repository is read this way
in a test and compared with the output the CLI is pinned to; cargo run --example read_device (and read_k8s_deploy, read_service_catalog) shows it whole.
Calling into a script: Script
eval_file returns JSON, and JSON cannot hold a function. But an Aura pub def
is a value, so rules can live in a .aura file and the application can call
them — per request, per event, per batch — changing behaviour by editing the file
rather than rebuilding. Script is the type for that (D33).
#![allow(unused)]
fn main() {
use aura_lang::{Budget, Script};
#[derive(serde::Serialize)]
struct Event { device: String, temp_c: f64 }
#[derive(serde::Deserialize)]
struct Summary { total: u64, alerts: u64 }
let rules = Script::builder()
.file("telemetry.aura")
.budget(Budget::per_call().steps(5_000_000).memory_mb(32))
.load()?; // parse, analyse, prove hermetic — once
let summarize = rules.function::<(&[Event],), Summary>("summarize")?; // name, kind, arity — checked now
for chunk in events.chunks(10_000) {
let s = summarize.call((chunk,))?; // one call, one budget, nothing left behind
}
}
What it guarantees, in the order the decision ranks them:
- Hermetic by default. With no grants,
loadproves statically that the script reads no file, variable or clock, and fails withE0505if it could — on a branch that never runs, too.allow_envandallow_readgrant exactly what--allow-envand--allow-readdo, and reach the root module only. - Every call has a budget, counted in steps and bytes — never in time, which
would make the outcome depend on the machine. A runaway ends with
E0325orE0326instead of stalling the host. The default is finite. - Memory per call is constant. Each call runs in its own interpreter and releases what it made when it returns.
- The boundary is checked. Arguments cross by
serde; an integer pasti64or a NaN isE0005rather than a silently different input, and a result that does not fit your type isE0006, naming the path inside it. ScriptisSend + Sync. One instance serves every thread.
Measured on one core: a call costs about 1.2 µs, and a rule over events processes
roughly 0.9 million events a second called per event, 1.3 million in batches —
cargo bench --bench script. cargo run --example embedding runs the example
above end to end.
Reloading rules while the service runs
Rules change more often than the service that runs them. A script loaded from a file can be loaded again without a restart (D36):
#![allow(unused)]
fn main() {
use std::time::Duration;
let rules = Script::builder()
.file("routing.aura")
.watch(Duration::from_secs(2), |result| match result {
Ok(v) => log::info!("rules at generation {} ({})", v.generation, v.hash),
Err(e) => log::error!("rules not reloaded, the old ones keep serving: {e}"),
})
.load()?;
rules.reload()?; // or reload now, with or without watch
}
- A reload is a load. It runs every check the first load did, with the same
grants: it can never make a script less hermetic, reach a host function you
did not provide, or pull in a package
aura.lockdid not pin - the lock is read once, and a reload resolves as--frozendoes. - The exports are a contract. Each export keeps its kind and number of
arguments, or the reload is
E0011and the old version keeps serving. New exports are fine. A boundFunctionfollows its name across versions. - Never half a version. A call finishes on the version it started on; the old version is freed when the last such call returns.
- Content decides. A
touchloads nothing. While watching, a change is loaded only once the files have been left alone for the settle period - two intervals and at least a second, or.settle(d)- so a file still being written is not loaded half done. A writer that pauses mid-write for longer than that can still be caught: deploy by writing a new file and renaming it over the old one, as editors do, and raise.settlefor a slow network share. - No silent failure. The handler
watchrequires hears of every automatic reload - the new version, or the error - and of the same broken content once. version()names the generation and a hash of every file, for your logs.
cargo run --example checkout puts it all together: an online store’s risk
rules, given a GeoIP table, exchange rates and a dispute database by the
service, reloaded on an edit - and refusing an edit that renames what the
service calls, or that reaches for a secret it was never given.
Advanced: a script that calls its host
Some rules need data only the application has - which region an address is in, whether a feature is on for a customer. Rather than computing every such value in advance, the host can give the script named functions to call (D35). This is an advanced use: read the guarantees below before relying on it.
import "host:geo" as geo
pub def route(ip)
region = geo.region(ip)
backend: region == null ? "global" : "#{region}-cluster"
end
#![allow(unused)]
fn main() {
let rules = Script::builder()
.file("routing.aura")
.pure("geo.region", move |ip: String| geoip.get(&ip).cloned()) // Option: None is null
.effect("flags.enabled", move |flag: String, customer: String| {
flags.enabled(&flag, &customer) // Result: Err is E0008
})
.load()?;
}
pure and effect are methods of the Rust API, not words of the language. The
part of the name before the dot is the module the script imports.
pureis your promise, and the language cannot check it. A pure function must give the same answer for the same arguments - a table that ships with the binary, a formula. It leaves the load hermetic. Anything that reads the world - a database, a flag service, the clock - is aneffect: registering it is a grant, likeallow_env, and the load is no longer hermetic.- The root module only. A package the script imports cannot call host
functions;
grant_to_imports("geo.region")opens one function to imports. - Checked at load. A module or function the host did not provide is
E0007, a call with the wrong number of arguments isE0009- on branches that never run, too. Argument types are checked at the call, also asE0009. - Budgeted. A host call costs 100 steps (
host_call_stepschanges it). The time spent inside your function is invisible to the budget: bound it yourself. - No call back. A host function that calls into the script gets
E0010.
aura eval has no host, so a host: import there stops with E0007;
aura check lists what the script needs from whichever application runs it.
cargo run --example host_functions runs a complete example.
Any language: a subprocess
The CLI contract is stable and is an API: JSON on stdout, diagnostics with
E0xxx / W0xxx codes on stderr, exit codes 0 (success), 1 (diagnostics),
2 (I/O or arguments).
import json, subprocess
r = subprocess.run(["aura", "eval", "app.aura", "--frozen"],
capture_output=True, text=True)
if r.returncode != 0:
raise RuntimeError(r.stderr)
config = json.loads(r.stdout)
Recommended in production:
--frozen— dependencies strictly as recorded inaura.lock;- minimal, explicit rights (
--allow-read,--allow-env=NAMES); --format yaml|tomlif that suits the consumer better.
Mobile and browser applications
The right pattern is to evaluate on a server or in CI:
configs.aura ──aura eval──▶ config.json ──▶ CDN / bundle
the device reads finished JSON with its platform's own parser
Applications consume the result of Aura; validation already happened in CI.
Why wrappers rather than native reimplementations
Every language has its own YAML parser, so it is fair to ask why Aura does not
work that way. Because YAML is a data format: porting it means writing a parser,
bytes in and data out. Aura is an evaluated language — functions, imports,
schemas, capabilities, determinism — so porting it means writing an interpreter
that is obliged to match this one byte for byte. Otherwise the same manifest
yields different values in CI and in the service, which is to say in production.
The divergence is not exotic: a naive implementation on Go’s encoding/json
emits 1 where Aura emits 1.0, and 1000000000000000000 where Aura emits
1e+18.
So there is one implementation, and other languages reach it through:
- Evaluation at build time — the main path. No Aura runtime is needed inside
your language at all, and
aura typeswill additionally generate types for your JSON. - Wrappers around the same core — on the roadmap, in this order: WASM/npm
(one artifact covers Node, the browser and a documentation playground), then
wazerofor Go (pure Go, no cgo) and wasmtime for Python; PyO3 and UniFFI as demand appears.
The core already builds for wasm32-unknown-unknown; CI checks that on every
commit, and runs the resulting module under Node so that “it compiles” is not
mistaken for “it works”.
CLI
aura eval <file.aura> [--strict] [--dry-run] [--frozen]
[--allow-read=<dir>]... [--allow-env[=A,B]] [--allow-imports-io]
[--hermetic] [--max-steps=N] [--max-memory=MB] [--stats]
[--format json|json-flat|yaml|toml] [-o out] [--registry-dir=<dir>]
aura check <file.aura> [--strict] [--hermetic]
aura docs --agent [-o <file>]
aura fmt <files...> [--check]
aura types <file.aura> --lang rust|ts|go [--out <file>]
aura add <path>@vX.Y.Z [--from <file>] [--registry-dir=<dir>]
aura --version
eval
Evaluates a manifest with all its imports and prints the result.
| Flag | Effect |
|---|---|
--strict | analysis warnings (from every module) become blocking; extra schema fields are error E0513 |
--dry-run | a full evaluation, but neither the output nor aura.lock is written; reports [dry-run] read/would write |
--frozen | dependencies strictly per aura.lock (E0403 on a mismatch), and the lock is never rewritten — the CI mode |
--allow-read=<dir> | permits read_file() inside that directory (repeatable). A relative path in read_file() is read from the manifest’s own folder, so the output does not depend on where aura runs (D37) |
--allow-env[=A,B] | permits env(): either a list of names, or all of them |
--allow-imports-io | extends the root’s rights to imported modules |
--hermetic | no I/O at all: env() and read_file() are E0505 wherever they appear. Mutually exclusive with the --allow-* flags |
--max-steps=N | expressions the evaluation may run before it stops with E0325. Default ten million |
--max-memory=MB | mebibytes of values it may create before it stops with E0326. Default 256 |
--stats | print the steps and bytes the evaluation spent, to stderr |
--format | json (default), json-flat, yaml or toml |
-o, --output <file> | write to a file instead of stdout |
--registry-dir=<dir> | the package cache directory (default ~/.aura/registry) |
check
Lex, parse and static analysis only — a fast gate for pre-commit hooks and CI.
--hermetic additionally requires that the manifest performs no I/O: env() and
read_file() anywhere in it, or in anything it imports, is E0505. It is decided
statically, so check answers it without evaluating — including for branches that
this particular run would not have taken:
$ aura check --hermetic deploy.aura
[E0505] Error: env() is not allowed in hermetic mode
╭─[ deploy.aura:4:11 ]
Reach for it when a manifest’s output must depend on nothing but its own text: a
CI gate on a directory of manifests, or a build whose result you want to be
reproducible on another machine. eval --hermetic enforces the same thing, and is
the right flag when the manifest is untrusted.
A script written for an application that provides host functions (D35) imports
them as import "host:geo" as geo. The CLI has no host, so eval stops with
E0007; check lists what the script needs from the application that will run
it, and exits 0:
$ aura check routing.aura
note: provided by the application at run time, this script calls:
geo.region (1 argument)
flags.enabled (2 arguments)
ok: routing.aura
docs
aura docs --agent prints the complete language reference — syntax, the standard
library, every diagnostic code — assembled from the compiler’s own definitions.
It is about four thousand tokens, which is small enough to hand to a coding
assistant whole.
The reason to print it from the binary rather than link to a page is versions: the
output describes the aura that produced it, and cannot drift the way a hosted
document can.
The most useful way to use it is to let the assistant fetch it. One line in your
repository’s AGENTS.md, CLAUDE.md or equivalent:
Before writing or editing .aura files, run `aura docs --agent` for the complete
language reference.
Or commit it, for assistants that read files but cannot run commands:
$ aura docs --agent -o AURA.md
The same text is published at /llms.txt for tools that fetch over the network.
fmt
The canonical formatter: indentation (two spaces per level, by token depth),
intra-line spacing, and column alignment of runs of name = value, key: value
and cond arms together with their trailing comments. Strings and the contents of
block strings are left alone; the token stream is guaranteed not to change, and
formatting is idempotent. --check only checks, exiting 1 if anything differs.
types
Generates host-language types from the type and enum declarations:
aura types config.aura --lang ts
The six built-in field types map like this:
| Aura | Rust | TypeScript | Go |
|---|---|---|---|
String | String | string | string |
Int | i64 | number | int64 |
Float | f64 | number | float64 |
Bool | bool | boolean | bool |
List | Vec<serde_json::Value> | unknown[] | []any |
Object | serde_json::Map<String, serde_json::Value> | Record<string, unknown> | map[string]any |
String is the only name the language accepts for a text field — there is no
Str.
Note the last two rows. A schema’s List and Object carry no element type, so
the generated code cannot either: you get a sequence or a map of untyped values.
Where the shape matters, declare a type for the element and let the field hold
that instead.
One schema both validates the config and types the service that reads its JSON.
An enum becomes a string-literal union (TypeScript), an enum with
#[serde(rename)] (Rust), or a named string with typed constants (Go). Optional
fields (D15) are emitted as required, because evaluation always substitutes the
default. Parsing only: the manifest is not evaluated and no capabilities are
involved. --out writes to a file.
The output is already canonical for the host language’s formatter — rustfmt --check, gofmt -l and prettier --check stay silent on the generated files —
so it can be committed into a service’s repository without formatting churn in
diffs.
add
Installs a package: downloading it (github/<owner>/<repo> → package.aura at
tag vX.Y.Z; an exact version is mandatory), validating it, writing it to the
cache and recording it in ./aura.lock with an integrity hash. --from <file> installs
from a local file instead. This is the only command that uses the network.
Exit codes
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | diagnostics (language errors, a failed assert, a blocking strict warning) |
| 2 | I/O and argument errors |
Keywords
Aura has sixteen reserved words. None of them can name a binding, a function, a
schema or an unquoted key: type = 1 is a syntax error. A key that has to be one
of these words is written as a string, and read the same way (D11):
"type": "service", then o."type".
| Word | What it does | Example | Decision |
|---|---|---|---|
import | Brings in another module, a registry package with a pinned version, or a module the host provides | import "./lib.aura" as lib | D8, D12, D35 |
as | Names what import brought in; only after an import | import org/net@v1.2 as net | D12 |
type | Declares a schema: fields with types, defaults and invariants. A field typed by a schema holds only what new of it would accept | type Port … end | D15, D26, D27, D28, D41 |
enum | Declares a closed set of strings for a schema field | enum Tier … end | D18 |
def | Declares a named function. It returns the object of its properties, or its last expression when it ends in one | def labels(team) … end | D17, D40 |
end | Closes every block: objects, def, type, enum, domain, cond, new, a multi-line lambda and a text string | tls: enabled: true end | D3, D38 |
domain | A named section with its own scope: its label becomes the key, and its body may compute | domain "production" … end | D17 |
new | Builds a schema instance and checks it | new Port number: 443 end | D4, D38 |
assert | A check that stops evaluation with a message; inside a type, an invariant carried with the schema | assert replicas > 0, "need one" | D5, D28 |
shadow | Rebinds a name from an outer scope - without it, that is E0302 | shadow port = 9090 | D7 |
pub | Makes a def, type or enum importable; properties are always visible | pub def service(name) | D12 |
cond | A choice between several values, the first true arm wins | cond … else -> "dev" … end | D14 |
else | The last arm of a cond, required so every path has a value | else -> "dev" | D14 |
true | The Bool true. There is no truthiness: a condition that is not a Bool is E0306 | tls: true | - |
false | The Bool false | debug: false | - |
null | The absence of a value. A schema field takes it only when its type is marked ?, and it is never rendered as text | quota: Int? = null | D27, D31 |
Not reserved
| Word | What it does | Example | Decision |
|---|---|---|---|
text | Opens a block string, but only where a value is expected - key: text or x = text at the end of a line. Everywhere else it is an ordinary name, so a field called text keeps working | body: text … end | D16 |
The type names - String, Int, Float, Bool, List, Object - are not
keywords either: they are names the schema checker knows. The decisions in the
last column are in the decision log of the specification.
Built-in methods
Methods are called with a dot and they chain. Every signature below was checked against the implementation rather than transcribed.
String
| Method | Result | Notes |
|---|---|---|
upper() / lower() | String | "auth".upper() → "AUTH" |
len() | Int | counts characters: "héllo".len() → 5 |
trim() | String | strips surrounding whitespace |
split(sep) | List | "a,b".split(",") → ["a", "b"] |
replace(from, to) | String | every occurrence |
starts_with(prefix) / ends_with(suffix) | Bool | |
contains(substr) | Bool | "hello".contains("ell") → true |
to_int() / to_float() | Int / Float | a malformed number is an error, not null |
slice(start, end) | String | substring by character index, end exclusive, indices clamped |
to_str() | String | identity, for use in chains |
parse_json() / parse_yaml() / parse_toml() | Object | integers become Int; a failure is E0314 |
| ↳ fidelity | key order is the document’s; YAML << is merged; a TOML datetime becomes a String | |
parse_duration() | Int (seconds) | "1h30m" → 5400; units d/h/m/s; E0319 |
parse_datetime() | Int (epoch UTC) | RFC 3339 or YYYY-MM-DD; offsets ±HH:MM; E0320 |
sha256() | String | lower-case hex digest |
base64() / base64_decode() | String | standard alphabet with padding; invalid input is E0321 |
Int
| Method | Result | Notes |
|---|---|---|
abs() | Int | |
to_float() | Float | widening; there is no Float.to_int(), which would have to round |
to_str() | String | |
format_duration() | String | 5400 → "1h30m"; 0 → "0s" |
format_datetime() | String | RFC 3339 UTC: 946684800 → "2000-01-01T00:00:00Z" |
Float
| Method | Result |
|---|---|
abs() | Float |
round(digits) | Float |
to_str() | String |
Bool
| Method | Result |
|---|---|
to_str() | String |
List
| Method | Result | Notes |
|---|---|---|
len() | Int | |
first() / last() | element | an empty list is E0317 |
get(i) / get(i, default) | element | a miss gives null, or the default if you supply one |
contains(item) | Bool | structural equality |
compact() | List | drops null |
uniq() | List | deduplicates, keeping first occurrences in order |
map (x, i) -> ... end | List | the callback receives the element and its index |
filter (x, i) -> Bool end | List | a non-Bool from the callback is E0306 |
join(sep) | String | scalars only; containers are E0307 |
sort() | List | |
reverse() | List | |
sum() | Int | |
min() / max() | element | |
flatten() | List | one level: [[1], [2, 3]] → [1, 2, 3] |
to_object() | Object | builds an object from { key, value } pairs; a repeated key is E0323 |
reduce(init) (acc, e) -> … end | Any | left fold; the initial value is required |
any (e, i) -> Bool end | Bool | some element matches; false for an empty list |
all (e, i) -> Bool end | Bool | every element matches; true for an empty list |
find(default) (e, i) -> Bool end | Any | first match, or the default |
index_of(value, default) | Int | index of the first equal element, or the default; the default must be an Int |
group_by (e, i) -> key end | Object | elements by a String key, keys in the order they first appear (D39) |
sort_by (e, i) -> key end | List | ascending and stable; keys follow sort() (D39) |
flat_map (e, i) -> [...] end | List | map, then the lists joined one level; a non-List from the lambda is E0306 (D39) |
zip(other) | List | { left, right } pairs; lists of different lengths are E0327 (D39) |
slice(start, end) | List | end exclusive: [1, 2, 3].slice(1, 3) → [2, 3] |
to_json() / to_yaml() / to_toml() | String | the compact form, for embedding; to_toml on a list is E0603 |
to_json_pretty() / to_yaml_pretty() / to_toml_pretty() | String | the form a person reads, for a file that gets diffed |
Object
| Method | Result | Notes |
|---|---|---|
len() | Int | |
keys() / values() | List | declaration order |
contains(key) | Bool | |
get(key, default) | value | a miss gives the default |
entries() | List | { key, value } objects in declaration order; the inverse of to_object() |
merge(other) | Object | the right side’s keys win |
to_json() / to_yaml() / to_toml() | String | the compact form, for embedding |
to_json_pretty() / to_yaml_pretty() / to_toml_pretty() | String | the form a person reads, for a file that gets diffed |
Operators that build values
Most operators are numeric. Two are not, and they are the only ones whose result can be larger than what went in.
| Expression | Result | Notes |
|---|---|---|
"a" + "b" | String | joins two strings; interpolation "#{a}#{b}" does the same |
[1] + [2, 3] | List | joins two lists; the operands are unchanged |
+ never converts. "port " + 8080 is E0306, and the diagnostic names the
side to call .to_str() on. Joining is bounded at 1,000,000 list elements and
16 MiB of string, reported as E0322.
Global functions
| Function | Right required | Behaviour |
|---|---|---|
range(n) | — | [0, 1, … n-1] |
env(name, default) | --allow-env | an environment variable; absent → the default |
read_file(path) | --allow-read | the file’s contents as a string; a relative path is read from the manifest’s folder (D37) |
fail(message) | — | stops with E0531 and your message |
A method call takes precedence over an object field of the same name that happens
to hold a function. A package’s exported functions are called with the same
syntax: pkg.fn(...).
Error and warning codes
Every Aura diagnostic carries a stable code, which makes it easy to grep for in CI
logs and to refer to in discussion. E is an error, W a warning — and under
--strict warnings block too.
Host (E00xx)
Raised at the boundary between a manifest and the application that embeds it.
E0001-E0006 carry no source position, because nothing was parsed yet;
E0007-E0009 point at the call in the script (D35).
| Code | Meaning | How to fix it |
|---|---|---|
| E0001 | the host could not open a file, or aura.lock is unreadable | the message carries the underlying I/O error |
| E0002 | the script exports nothing by that name | check the name; only : properties and pub def are exports |
| E0003 | the export is not a function | bind it with get, not function or call |
| E0004 | the function takes a different number of arguments | checked when the function is bound, before any call |
| E0005 | an argument has no exact Aura value | an integer past i64, a NaN or an infinity, a byte string; refused rather than coerced |
| E0006 | the result does not match the host’s type | the message names the path inside the value where it did not |
| E0007 | the script imports or calls a host function the host did not provide | a host: module is provided by the application at run time, so aura eval stops here; aura check lists what the script needs |
| E0008 | a host function returned an error | the message is the application’s; the script cannot recover from it |
| E0009 | a host function was called with the wrong number of arguments (at load) or the wrong types (at the call), or returned a value Aura cannot hold | the message names the function |
| E0010 | a script was called from inside one of its own host functions | not allowed (D35): return a value to the script instead |
| E0011 | a reload would change what the script exports: an export removed, or its kind or number of arguments changed | the host may have bound it, so the old version keeps serving (D36); restart to change the exports |
Lexing (E01xx)
| Code | Meaning | How to fix it |
|---|---|---|
| E0101 | no digit after the decimal point (12.) | write 12.0 or 12 |
| E0102 | a string left unclosed at end of line | Aura strings are single-line; close the " |
| E0103 | an integer literal does not fit in i64 | |
| E0104 | a registry import without a version | import a/b@v1 as x — the version is mandatory (D8) |
| E0105 | unexpected character | |
| E0106 | an unclosed #{ interpolation | |
| E0107 | an unterminated block string | close it with end at the opener’s indentation (D16) |
| E0108 | a digit separator is misplaced | _ groups by threes away from the point: 10_000_000, 0.000_001 — never 100_, 1__0 or 2_3_7_1_9_3_3 |
Syntax (E02xx)
| Code | Meaning | How to fix it |
|---|---|---|
| E0200 | a specific token was expected | the message names which |
| E0201 | a v1.1 inline block (metrics port: 9090) | use an object block: metrics: … end (D3) |
| E0203 | a missing end or ] | |
| E0204 | an expression was expected | |
| E0205 | statements are separated by line breaks | |
| E0206 | pub not immediately before def, type or enum | properties export themselves; = is always private |
| E0207 | a cond with no else arm | add else -> ...: every branch must produce a value (D14) |
| E0208 | nesting too deep | the recursion limit that makes deeply-nested input an error rather than a stack overflow; a chain like a + b + c counts its length (D42) |
| E0211 | an enum member is not a quoted string | members are listed as strings, one per line (D18) |
| E0212 | a duplicate enum member | list each member once |
| E0213 | an enum with no members | an empty set cannot be satisfied |
| E0214 | a value is computed and thrown away | a bare expression outside the end of a function goes nowhere; write key:, name = or assert (D40) |
| E0215 | a body has properties and also ends in a value | a function returns one or the other; make the value a property, or drop the properties (D40) |
Evaluation (E03xx)
| Code | Meaning | How to fix it |
|---|---|---|
| E0301 | assigned twice — values are immutable | use a new name, or a nested scope |
| E0302 | shadowing an outer variable without the marker | shadow x = ... (D7) |
| W0303 | shadow shadows nothing | drop the shadow |
| E0304 | integer arithmetic overflowed | use Float, or reconsider the magnitudes |
| E0305 | division by zero | |
| E0306 | an operand of the wrong type | conditions must be Bool; there is no truthiness |
| E0307 | a non-scalar in interpolation or join | serialise it explicitly with .to_json() |
| E0308 | unknown object field | a typo? use .get(key, default) for optional ones |
| E0309 | unknown method | see the method reference; a package’s function fields are called the same way |
| E0310 | missing capability | the error names the flag you need (D1) |
| E0311 | a path outside the directories allowed by --allow-read | |
| E0312 | fewer arguments passed than the function has parameters | |
| E0313 | a file read failed (I/O) | |
| E0314 | malformed TOML, JSON or YAML while parsing | |
| E0315 | a method that needs a lambda (map, filter) did not get one | |
| E0316 | a malformed expression inside #{...} | |
| E0317 | a list index out of range, or first() / last() on an empty list | use .get(i, default) |
| E0318 | obj["key"] — brackets are not allowed on objects | use the dotted form: obj."key" (D11) |
| E0319 | a malformed duration | the format is "1h30m", units d/h/m/s |
| E0320 | a malformed date | RFC 3339: "2026-07-18T12:00:00Z" |
| E0321 | malformed base64 | base64_decode() on input that is not base64 |
| E0322 | a concatenated list or string exceeds its size limit | + doubles: 1M elements / 16 MiB is the ceiling |
| E0323 | to_object() got the same key twice | a silent overwrite would depend on element order; deduplicate first |
| E0324 | null rendered as text | interpolation and to_str() must agree; say what the empty case prints (D31) |
| E0325 | an evaluation ran out of its step budget | --max-steps raises it, --stats shows what is spent (D34); in Script, the budget is per call (D33) |
| E0326 | an evaluation ran out of its memory budget | --max-memory raises it; every value and frame created counts (D34) |
| E0327 | zip() got lists of different lengths | a dropped tail would drop configuration silently; cut the longer one with slice first (D39) |
| E0328 | a value nested more than 256 lists and objects deep | almost always a loop that wraps its result each turn; build the levels side by side instead (D43) |
| E0398 | internal: a closure’s environment was dropped | a bug in Aura — please report it with the manifest |
| E0399 | evaluation nested more than 256 levels | almost always a recursive function without the case that stops it; map, filter and reduce walk a list without nesting (D42) |
| E0533 | now() and timestamp() do not exist | the host passes time in: env("BUILD_TIME", ...) (D13) |
Modules and packages (E04xx)
| Code | Meaning | How to fix it |
|---|---|---|
| E0401 | a cyclic import (the chain is in the message) | break the cycle: move shared code into a third module |
| E0402 | a package’s integrity hash does not match aura.lock | the package’s contents changed — find out why |
| E0403 | --frozen: no entry in aura.lock | run aura add locally and commit the lock |
| E0404 | module not found, or does not resolve | check the path, the version, --registry-dir |
| E0410 | an import the interpreter was handed without the module loader | only reachable when embedding the evaluator directly; use facade::eval_file/eval_source, which load modules |
Analysis and validation (E05xx / W05xx)
| Code | Meaning | How to fix it |
|---|---|---|
| W0501 | an unused variable | dead code — delete it |
| W0502 | an unused import | |
| W0503 | an unused function or type | if it is a package’s API, mark it pub |
| E0504 | an undeclared variable | a declaration must precede its use |
| E0505 | env() or read_file() under --hermetic | pass the value in from the host, or drop the flag and grant the capability |
| E0511 | a field the schema requires is missing | |
| E0512 | a field’s type does not match the schema | Int and Float are different types |
| E0513 | an extra field (--strict only) | remove the field, or add it to the schema |
| E0514 | a value outside the enum | the hint suggests the nearest member and lists the set (D18) |
| E0515 | a schema invariant does not hold for this instance | the schema’s own assert; the message shown is the one it carries |
| E0516 | a default its own field can never accept | checked where the schema is declared, not where it is used |
| W0512 | an effectful call in an imported module | imports have no I/O rights; reconsider the package’s design |
| W0513 | a nullable field is filled from get(key) or env(name) with no fallback | absence becomes null silently; name the fallback, or drop the ? |
| E0530 | an assert failed | the message is your own |
| E0531 | fail(...) was called |
Serialisation (E06xx)
| Code | Meaning | How to fix it |
|---|---|---|
| E0601 | a function or schema inside the exported tree | the key path is in the message; at the top level pub items are excluded automatically |
| E0602 | a non-finite Float (NaN or Inf) | |
| E0603 | a format limitation (TOML has no null; its root must be an object) | |
| E0604 | two keys flatten to the same json-flat key | a key containing a dot is indistinguishable from a nested path; use --format json, or rename the key |