Repository navigation
Releases
v0.4.0
An embedding release. A script can call functions its host provides (D35),
be reloaded while the service runs (D36), and be read whole into a host's own
types. One behaviour change: read_file now reads a relative path from the
manifest's folder (D37) - a run from the manifest's own folder is unaffected.
Changed
read_filereads a relative path from the manifest's folder (D37), as an
import does - not from wherever the process was started. The same manifest
now gives the same result from any directory, and a service embedding it
throughScriptfinds the files beside it. A function reads from the folder
of the module that defined it. What may be read is unchanged: the granted
roots stay relative to the current directory, and a manifest outside them is
E0311, with a help line naming the folder and the flag that grants it. A
run from the manifest's own folder behaves as before.
Added
Script::outputandScriptBuilder::env_values: a whole manifest, read
into your own types.output()returns whataura evalprints, as any
Deserializetype.env_values([(name, value)])grants exactly those
variables with exactly those values;env()never falls back to the process,
so a host's environment cannot leak in. Every example in the repository is
read this way in a test and compared with the output the CLI is pinned to.- Three examples read from Rust, as programs to copy from:
cargo run --example read_device,read_k8s_deployand
read_service_catalog, with the structsaura types --lang rustwrites for
their schemas - checked in a test to be what the generator writes today. Float.round(digits).(640.0 * 2.0 + 9.5) * 0.92is
1186.3400000000001, and there was no way to show it as1186.34. Rounding
is of the value the Float holds, so1.005.round(2)is1.0on every
machine; an exact binary tie goes to the even digit, as in IEEE 754. The
result stays a Float, anddigitsis an Int from 0 to 15 (E0306
otherwise).- A script can be reloaded while the service runs (D36).
Script::reload()
loads the files again;ScriptBuilder::watch(interval, on_reload)does it when
their content changes - atouchloads nothing, and a file still being written
is not loaded until it has been left alone for the settle period (two
intervals and at least a second, or.settle(d)). A reload is a full load
with the first load's grants and itsaura.lock, frozen: it can never get more
than the first load was given. Every export keeps its kind and number of
arguments, or the reload isE0011and the old version keeps serving; a bound
Functionfollows its name. Calls in flight finish on the version they began
on.on_reloadis required, so a failed automatic reload cannot pass
unnoticed.Script::version()returns the generation and a content hash. - A script can call functions its host provides (D35). The script imports
them -import "host:geo" as geo- and the host registers them with
ScriptBuilder::pureorScriptBuilder::effect. Apurefunction is the
host's promise of determinism and leaves the load hermetic; aneffectis a
grant, likeallow_env. Host functions reach the root module only, unless
grant_to_importsopens one to imports. Every use is checked at load: a
module or function not provided isE0007, a wrong number of arguments
E0009. At the call, wrong types areE0009, anErrfrom the host is
E0008, and a host function that calls back into the script getsE0010.
Each call costs 100 steps of the budget (host_call_steps).aura evalhas
no host, so ahost:import there isE0007;aura checklists what the
script needs, and the language server explains the import line.
v0.3.1
A data-integrity release: a JSON object could be read as a number. No new
API, nothing to change in a manifest.
Fixed
- A JSON object whose key was
$serde_json::private::Numberwas read as a
number.{"$serde_json::private::Number": "123"}came back from
parse_json()as the integer 123, with no error. The key is serde_json's own
internal marker: Aura read numbers through itsarbitrary_precisionmode,
which represents a number as an object with exactly that key, and so could
not tell the two apart. Present since 0.2.0; found by the nightly
ClusterFuzzLite run, which read such a key from YAML, wrote JSON and failed to
read it back. Numbers are now read withfloat_roundtripinstead, which keeps
the precision fix from 0.2.0 – the same digits still mean the same number in
every format – without a marker that data can collide with.
v0.3.0
A security and embedding release. Script (D33) lets a Rust host call into
Aura safely; every evaluation – the CLI, the facade, the playground and
Script – now has a budget (D34). The budget closes a hole in 0.2.0, where a
95-byte manifest that passes check --hermetic could run for over a minute and
hang a CI job that evaluated it.
Changed – may need an edit
- Every evaluation has a finite budget by default: 10 million steps and
256 MiB, exhausted asE0325/E0326. Measured against every example in
the repository, the heaviest spends 302 steps and 17 KB. A manifest that
legitimately needs more passes--max-steps/--max-memory;--stats
shows what it spends.
Added
-
Every evaluation has a budget, not only
Script(D34).aura eval,
eval_fileand the browser playground used to evaluate with no limit, and
aura evalruns third-party packages: a 95-byte manifest that passes
check --hermeticran for over a minute, which in CI is a build that hangs
until its job times out. The default is the same everywhere – 10 million steps
and 256 MiB, exhausted asE0325/E0326– and was measured rather than
guessed: the heaviest example in the repository spends 302 steps and 17 KB.
--max-stepsand--max-memorychange it, and--statsprints what an
evaluation spent;Evaluated::usagereports the same to a host. A manifest
that legitimately needs more now has to say so. -
Script: calling into Aura from Rust (D33). A host loads a script once and
calls itspub deffunctions as often as it likes, from as many threads as it
likes, with host types crossing throughserde:let rules = Script::builder().file("rules.aura").load()?; let route: Route = rules.call("route", (path, method))?;
The route this replaces drove the compiler's internals by hand and went around
the facade, so it had no capability model, no analysis and no diagnostics.
Measuring it found two defects that ruled it out for a long-running process:
every call kept about 2 KB alive forever, and a 95-byte manifest that passes
check --hermeticran for over a minute.- Hermetic by default: with no grants,
loadproves the script reads no
file, variable or clock (E0505). Grants mirror the CLI's flags. - A budget on every call, in steps and bytes – never in time, which would
make the outcome depend on the machine.E0325/E0326; the default is
finite. - A checked boundary: a value Aura cannot hold exactly is
E0005, and a
result of the wrong shape isE0006with the path inside it. Binding a
function checks its name, kind and arity once (E0002–E0004). Send + Sync, so one instance serves every thread.
Measured on one core: about 1.2 µs a call, against 3.8 µs on the old route;
0.9 million events a second called per event, 1.3 million in batches.
Tested before it was implemented –tests/script_api.rswas written against
the designed API – and fuzzed by the newfuzz_scripttarget. - Hermetic by default: with no grants,
Changed
- A function call releases its frames when nothing can refer to them. Every
call kept its environment until the whole evaluation ended, soxs.map(...)
over a long list held a frame per element – measured at gigabytes for one
ordinary-looking expression. Only a function value refers to an environment,
so a call that created none releases its frames on return. The evaluator
benchmarks are unchanged or faster. - Environments hold their bindings in an
RwLock, and the source arena in a
Mutex, so values can be shared across threads. Measured: no slowdown. Miri
passes on the arena under both Stacked and Tree Borrows. import "./lib.aura"resolves from in-memory sources as it does from
disk. The in-memory resolver looked the path up verbatim, so the same manifest
ran from a directory and failed withE0404from memory.
Fixed
- A lambda parameter declared inside
#{...}pointed at the wrong text.
Found by the first nightly ClusterFuzzLite run. Declarations made inside an
interpolation were recorded relative to the interpolated text rather than the
file, and searched for among the file's tokens, where the whole string is one
token. The language server clamped the position, so it did not crash – but
go-to-definition jumped elsewhere, and a rename would have rewritten unrelated
bytes. Plain ASCII hid it; a multibyte character before the interpolation
made it visible.
Security
rustls0.23.42 → 0.23.45, the TLS stackaura adduses to fetch packages –
the only place Aura touches the network.
Removed
examples/scripting.rs, the sketch that called apub defthrough the
compiler's internals.Scriptsupersedes it;cargo run --example embedding
shows the supported way.
v0.2.0
I design Aura and make every decision about it; this release was built together
with Claude, an AI model by Anthropic, which wrote the code and the tests that keep
the documentation honest. How Aura is built tells
the longer story.
The minor number moves because behaviour moved. Four changes reject or
re-render something 0.1.1 accepted, so a manifest that worked before may need
one edit — each is listed under Changed below with what to do about it.
Everything else is addition: typed list elements, nullable fields, schema
invariants, folds and predicates, + on strings and lists, digit separators,
substrings, widening, and a compact/spelled-out pair for every serialisation
format. Three reproducibility defects were also fixed, all of which could
silently produce a wrong configuration: a YAML merge key that was not applied,
TOML that came back alphabetised, and the same digits meaning different numbers
depending on which format carried them.
Changed — may need an edit
-
A schema's default value is now checked where the schema is declared
(E0516).q: Int = "not an int"used to passcheck --strictwith nothing
to report but an unused type; the mismatch waited fornew, and a field that
callers always supply explicitly never reachesnewwith its default at all —
so a schema could carry an impossible value indefinitely while the declaration
claimed otherwise. Only literal defaults are judged, because what an arbitrary
expression evaluates to is not knowable before it runs.A declaration that was already wrong will now say so.
q: Int = nullwants
q: Int? = null: the marker admits null and the default makes the field
optional — the marker alone still requires it atnew. -
Interpolating a
nullis nowE0324instead of printing the four letters
null(D31). Where a value can be absent, say what stands in for it:
"#{x == null ? "none" : x}", orxs.compact().join(", ")for a list. -
to_yaml()andto_toml()return the compact form (D32). If the string
is written to a file a person reads, useto_yaml_pretty()/
to_toml_pretty(). The CLI is unaffected:--formatstill writes the
spelled-out form. -
parse_toml()preserves the document's key order instead of sorting it.
Output that pinned the alphabetised order will differ; the new order is the
one the input file was written in.
Added
-
Every format has a compact and a spelled-out spelling (D32).
to_json(),
to_yaml()andto_toml()now give the most compact form the format allows;
to_json_pretty(),to_yaml_pretty()andto_toml_pretty()give the form a
person reads.Before this each emitter had made its own choice and the three had never been
compared against each other: JSON came out compact, YAML came out block style,
TOML expanded its arrays one element per line. Nothing explained the
difference, so the only way to know what a method returned was to run it.Compact is the default because a serialised string is usually embedded —
in a field of another configuration, in an environment variable, in a command
argument — where a newline has to be escaped and helps nobody. The spelled-out
form is for a file that lands in a repository, where one line per document
makes a diff useless. That is the whole rule, and the method set follows from
it rather than having to be memorised.Compact YAML is flow style,
{a: 1, xs: [1, 2]}, which is ordinary YAML and
not a dialect of it. Compact TOML keeps arrays on one line; a TOML document is
several lines whatever happens.This changes output.
to_yaml()andto_toml()return different strings
than before —to_json()was already compact. Across the whole example
corpus exactly one pinned line moved, in thedeviceshowcase, which now
demonstrates both spellings against the consumer each is for. The CLI is
unaffected:--format yaml|tomlstill writes the spelled-out form, because it
is producing a document for a person rather than a string for a manifest. -
nullhas no text form (D31)."#{x}"printed the four lettersnull
whilex.to_str()wasE0309, becauseto_stris not registered forNull.
One spelling of "render this as text" succeeded where the other refused, which
is the kind of divergence this language is meant not to have.It is closed downward: rendering a
nullas text is nowE0324in
interpolation, injoin, and in the message ofassert, an invariant or
fail(). Closing it upward — printingnulleverywhere — would have made the
common case silent, because the usual reason anullreaches a string is a
key that lost its value, and the wordnullin the output reads downstream as
a setting rather than as a missing one. That is whatW0513already warns
about.The diagnostic names the remedy for the site it came from, and each one is
executed by a test:#{x == null ? "none" : x}in a string,
xs.compact().join(", ")in a list. -
String.slice(start, end)andInt.to_float()(D30). Both gaps were
found by writing the device showcase, not by theory — the manifest carried a
comment apologising for the workaround.Trimming a trailing separator used to go through
split("/").filter(...).join("/"). It is now
topic.slice(0, topic.len() - 1), and the showcase's output is byte-identical,
which is what says the two were equivalent.slicecounts characters, not bytes, becauselen()does. If they
disagreed,s.slice(0, s.len())would be wrong the moment a manifest carried
anything outside ASCII — which configuration routinely does. Half-open, indices
clamped, same shape asList.slice.Int.to_float()widens. Before it, anIntreachedFloatby being divided
by1.0: correct under D6, sinceFloatis contagious, but it reads as a
trick rather than as intent. There is deliberately noFloat.to_int()—
narrowing would have to choose a rounding nobody asked for, and a lossy
conversion must not look like a change of spelling. -
Digit separators in numbers (D29).
max_bytes: 10_000_000, and in the
fractional part too, where groups run away from the point:0.000_001.Configuration is where long numbers actually live — byte limits, quotas,
retention windows. Miscounting a zero is the kind of mistake nothing else
catches: the value is valid, the type is right, and the service quietly gets
ten times the memory it should.It is optional and carries no meaning:
2371933and2_371_933are the same
token, and nothing asks you to group anything.Where used, it must group by threes, counted away from the decimal point —
so the short group sits at the front of an integer and the end of a fraction:
8_080,1.123_45. This is stricter than Go, Ruby and Rust, where
2_3_7_1_9_3_3is legal. A separator free to sit anywhere is a second way to
write one number, and it misleads:1_0000_000is ten million, but a reader
scanning for threes sees something far larger. The cost is Indian grouping
(1_00_00_000), which has to be written without separators.100_,1__0and1_.5are the newE0108as well. A leading_is still
an identifier, as it always was.Numbers written plainly stay zero-copy; only a literal that actually contains a
separator allocates. -
A schema can state rules about its own values (D28).
assertinside a
typebody, checked on everynew:type Plan price_monthly: Int price_yearly: Int assert price_yearly <= price_monthly * 12, "yearly must not exceed twelve months" endCross-field rules are the reason this is worth having — no per-field constraint
can relate two values. They need no ordering or cycle analysis either, because
every field is filled and type-checked before any invariant runs. That is what
makes this feasible where the dependent defaults of D15 were not.It travels with the schema. An importer instantiating
new pkg.Portgets
the package's rules, in a manifest whose author never read them. Written beside
thenewinstead, the rule is not part of the type: it does not move, tooling
cannot see it, and the next person does not write it. That is the argument D18
made forenum, applied to conditions.An invariant sees its own fields and nothing else — not the declaring
module, not the instantiation site. A rule that could read a module variable
would mean something different in each manifest that imported the schema, which
is the problem being solved; it also puts everything effectful out of reach, so
an instance is valid or invalid identically for everyone. An undefined name
there says so and lists the fields available.A failure is the new
E0515, naming the schema and carrying its message. -
A field can say that it may have no value (D27).
quota: Int?admits
null; every other field still rejects it.This exists to kill the sentinel. Without it, "unlimited" gets written as
-1
or2147483647, and everyone has to carry in their head which number means
what and where. A sentinel is a valid value of its type, so nothing catches a
wrong one — which makes it the worst kind of the implicit rule this language
keeps removing.?answers exactly one question: may the value be absent. Whether the field
may be left out is the separate job of= default, and the two compose:Declaration Omitting it Writing nullquota: IntE0511E0512quota: Int?E0511allowed quota: Int = 100gives 100E0512quota: Int? = nullgives nullallowed A nullable field is still required to be written, and its non-null values are
still type-checked. Nullability belongs to the field rather than to the type,
which makes ...
v0.1.1
A documentation and diagnostics release. No behaviour of the language itself
changed, and no manifest that worked on 0.1.0 needs touching.
Fixed
-
The agent reference taught a type that does not exist.
aura docs --agent
and the publishedllms.txtlistedStramong the built-in types and used it
in a schema example.Stris the Rust variant name; the language accepts only
String. An assistant following the reference wrote a field the parser rejects.Fixed at the class rather than the instance:
BUILTIN_TYPE_NAMESis now the
one list, the parser matches through it, the reference prints from it, and a
test fails ifStrreappears. -
An unknown field type said "undefined variable".
a: Strreported
use of undefined variable 'Str'and pointed at the wholetypeblock —
wording that never mentions types, sending the reader to look for a missing
binding. It now saysunknown type 'Str', points at the type name, lists the
six built-ins, and suggests the likely one.The suggestion needed more than edit distance:
StragainstStringis three
insertions, past the typo threshold, so the most probable mistake — an
abbreviation of the real name — got no hint at all. A prefix relation is tried
first now. -
Referring to a property by name explained nothing.
assert limits.max <= 1
wherelimits:is a property reported an undefined variable. True, and
useless::exports without creating a name, and that is the language's
central rule, so the diagnostic now teaches it instead of restating the
symptom. It fires whichever order the reference and the property appear in.
Documentation
- How to install Aura was documented nowhere. The quick start told the reader
to clone the repository and build from source — the instruction from before
crates.io, release binaries andsetup-auraexisted. Both READMEs and the book
now lead with the playground, which needs no install at all. - The README is rebuilt around what a reader needs first, in both languages:
installation up front, a sixty-second example, the language tour and the
verification table folded away, and badges for crates.io and docs.rs. - The type mapping used by
aura typesis documented for all three targets,
including the part that only showed up when generating: a schema'sListand
Objectcarry no element type, so neither can the generated code. - Embedding now says to turn default features off —
cargo add aura-langlocks
96 packages, and 38 without theclifeature the library never uses. SPECsaysStringwhere it describes something a user sees, notably the
E0512wording. The lexer'sTokenKind::Strkeeps its name, which is correct.
Added
- D19, open and not adopted: a host can already call a manifest's
pub def,
so rules can live in a.aurafile and be executed by an application per
request, with no intermediate JSON and no rebuild.
cargo run --example scriptingdemonstrates it and asserts its own results.
What is missing is ergonomics, and what is deliberately unresolved is the
other direction — a script cannot call back into the host, and opening that has
to be shaped like the capability model rather than bolted beside it. - The crate documentation now states which surface is supported:
facadeis
meant to stay stable; the layers beneath are public for tools and may change
while0.x.
v0.1.0
The first release. Aura compiles readable manifests to JSON, YAML or TOML,
with schemas, assertions that fail the build rather than the deploy, and a
capability model in which env() and read_file() are granted per run and the
grant does not reach imported modules.
Nothing shipped before this, so there is nothing to migrate from and no
compatibility section. While 0.x, minor releases may still change the
language.
Language
- Deterministic evaluation.
now()and randomness do not exist (D13); durations
and dates are epoch integers, viaparse_duration/parse_datetimeand their
formatters. The same manifest with the same inputs produces byte-identical
output. key:properties are exported,x =bindings are private (D10) — which is
also what makes dead-code analysis sound.- Schemas with type checking, and optional fields through
= default(D15). enum(D18): a closed set of allowed strings, usable as a schema field
type. A member is an ordinaryString, so output is unchanged; a non-member is
E0514, with a did-you-mean suggestion and the full member list.pub enumis
exported, and members resolve where the schema is declared, so an imported
schema validates against its own module's enum.- Immutability with an explicit
shadow(D7).IntandFloatare distinct
types (D6). - Packages:
pub def/pub type, versioned imports, andaura.lock(D8/D12).
An exported function runs with its origin module's capabilities, so
isolation cannot be borrowed by asking the caller to invoke it. - Multi-way
cond(D14),range(n), andtext … endblock strings (D16), whose
interiors interpolate with#{}while leaving braces alone — which is what
makes generating nginx configs and Dockerfiles practical. - Every code body is a scope (D17): a
defor lambda body takes statements
(=,shadow,assert) exactly as a module does. - Sixty standard-library methods across String, Int, Float, Bool, List and
Object.
Tooling
aura eval/check/fmt/types/add/docs, with--strict,
--frozen,--hermeticand--dry-run. Rich diagnostics through ariadne;
JSON, flattened JSON, YAML and TOML output.aura fmtis a canonical formatter: indentation, intra-line spacing, and
column-aligned runs ofname = value,key: valueandcondarms together
with their trailing comments. Strings and block-string interiors are untouched,
the token stream never changes, and formatting is idempotent — both fuzzed.aura typesgenerates host-language types from a manifest'stypeand
enumdeclarations (--lang rust|ts|go), so one schema both validates the
config and types the service consuming its JSON. Parsing only: no evaluation,
no capabilities, deterministic output, already canonical forrustfmt,
gofmtandprettier.aura docs --agentprints the complete language reference — syntax,
standard library, every diagnostic code — assembled from the compiler's own
definitions, at roughly four thousand tokens. Also published as
llms.txt.- Language server (
aura-lsp): live diagnostics, context- and type-aware
completion, hover, go-to-definition in-file and across modules, find
references, document symbols, rename (F2), signature help, and
formatting/format-on-save. Its completion database is built by evaluating an
Aura manifest with Aura itself.- Rename rests on scope-precise resolution, so
xin a lambda andxat the
top level are never conflated and uses inside#{…}are found. It refuses,
with a reason, rather than guessing: on a file with syntax errors, on
anything that is not a binding, on a malformed name, on a name already bound
in an overlapping scope, and onpubitems whose importers it cannot see. A
test renames every binding inexamples/showcaseand asserts the evaluated
JSON stays byte-identical. - Inside
new lib.Endpoint, a field typed by an importedpub enumoffers
exactly its members. Go-to-definition on a registry import opens the cached
package through the same resolver evaluation uses, so it cannot land on a
different file than the one that would be evaluated.
- Rename rests on scope-precise resolution, so
- A browser playground — the real compiler as WebAssembly, multi-file, with
aura fmtand diagnostics. Nothing is installed and nothing leaves the page. - Editor support: TextMate grammar for VS Code, plus Vim/Neovim and nano.
- The library and the CLI are one publishable crate:
cargo add aura-langto
embed,cargo install aura-langfor the tool. Theclifeature is
detachable, which is what keeps the library free of clap, ariadne and ureq and
able to build for wasm.
Security and supply chain
--hermeticturnsenv()andread_file()intoE0505in every module.
Because that is an analysis error,aura check --hermeticproves a manifest
performs no I/O without evaluating it — including for branches a given run
would not take.- Capability refusals distinguish their causes:
E0310when nothing was
granted,E0311when a grant exists but the path resolves outside it, with
the allowed directories named. aura.lockpins a package's exact version and a hash of its token stream
rather than its bytes, so reformatting or editing a comment does not fire the
integrity check while any change that can alter behaviour does. Locks written
with the older byte hash still verify, and are upgraded in place.- Six coverage-guided fuzz targets: lexer, parser, pipeline, formatter, codegen
and resolver.
Distribution
- Binaries for six targets on a tag — Linux gnu and musl, aarch64 Linux, macOS
Intel and Apple silicon, Windows — each with a.sha256. aura-config/setup-aura@v1
installs the CLI in GitHub Actions, verifying the checksum before unpacking.
On x86_64 Linux it installs the musl build: the gnu build requires
GLIBC_2.34and does not start on Ubuntu 20.04, Debian 11, CentOS 8 or Amazon
Linux 2, while the static build has no such floor and is measurably faster.packaging/e2e.shdrives the built binary through the claims this
documentation makes — exit codes, capability refusals, hermetic mode, output
formats, and byte-identical output across two runs. It runs on three operating
systems per push, and on a tag against the real artifacts inside five
containers, including aarch64 under emulation.
Performance
Measured on x86_64 Linux; re-run cargo bench -p aura-lang on your own machine.
- 258 MiB/s lexing, 178 MiB/s through the parser, 33 µs for a full
lex-parse-evaluate of the reference manifest. - The binary is 3.5 MB, a 1.7 MB download, with link-time optimisation and no
symbol table. LTO is not a size-against-speed trade here: it made the binary
17% smaller and about 9% faster.