aontu

Tutorial: build a config that checks itself

Rendered from docs/tutorial-config.md in the engine repository, where a correction belongs, and where the test suite executes every example on this page.

A service config needs values, constraints, and defaults. In aontu, you can write all three in one document and combine them through unification.

Build a config, validate data against it, and find which statement set a value. Each step shows source and output checked by the test suite. For the reasoning behind the operation, read the explanation.

1. Set up

If aontu is already installed, skip to §2.

TypeScript

Install and build inside the repo’s ts/ directory:

cd ts
npm install
npm run build      # compiles src + test into dist/ and dist-test/

A scratch play.js next to ts/ is enough to call the library:

const { Aontu } = require('aontu')   // when installed from npm
// from inside this repo: require('./ts/dist/aontu')

const aontu = new Aontu()
console.log(aontu.generate('hello: world'))
// { hello: 'world' }

generate takes aontu source text and returns a plain JavaScript value. That is the whole API surface you need today.

Go

The Go port lives in go/:

cd go
go test ./...      # confirms the toolchain works

A scratch main.go:

package main

import (
	"fmt"
	aontu "github.com/aontu-lang/aontu/go"
)

func main() {
	out, err := aontu.New().Generate("hello: world")
	fmt.Println(out, err)        // map[hello:world] <nil>
}

The two implementations accept the same source and produce the same shape; the parity is pinned by one test suite both implementations run. The rest of this page shows source and result: run them in whichever language you keep at hand.

The aontu command

Both implementations also ship an aontu command, and this page writes its CLI moments as transcripts of it. From a clone the command is node ts/bin/aontu.js (or go run ./cmd/aontu from inside go/); installed from npm (npm i -g aontu) it is plain aontu, which is how the transcripts spell it. Pipe a snippet in:

$ echo 'a:1 b:$.a' | aontu
{
  "a": 1,
  "b": 1
}

b followed a reference to a before printing: even a piped one-liner is fully evaluated. Run aontu with no file and you get a REPL; the API reference covers both.

Two verbs help before the language does. aontu init <dir> writes a model, an instance of it, and a check.sh that asks the four questions, which is a faster start than an empty file; aontu explain <code> prints the long form of a refusal, so a code in a message becomes a paragraph with examples.

2. Objects are just keys and values

aontu source reads as relaxed JSON (it is parsed by @tabnas/jsonic, so quotes, commas and braces are mostly optional). Plain data is legal on its own:

name: Mercury
order: 1
rocky: true

{ "name": "Mercury", "order": 1, "rocky": true }

No quotes on Mercury, no braces, no commas; the output is ordinary JSON all the same.

Nesting repeats keys with a colon: a:b:c:1 means a: { b: { c: 1 } }:

server: host: localhost
server: port: 8080

{ "server": { "host": "localhost", "port": 8080 } }

The two server: lines did not collide. They merged, and that merge is your first unification.

3. Unification: combining facts

Stating two things about the same place combines them. The explicit operator is &; between map keys it happens on its own:

server: { host: localhost }
server: { port: 8080 }

{ "server": { "host": "localhost", "port": 8080 } }

Where the statements agree, or one is more specific, unification keeps the combination. Where they disagree, we want an error, and we get one:

port: 8080
port: 9090

→ fails with:

[aontu/scalar_value]: Cannot unify values at path $.port

Cannot unify value: 9090 with value: 8080

(Trimmed; the real message also quotes both source lines, with a caret under each.) Combining information can only narrow toward a single answer or fail loudly. aontu never picks one fact over the other silently.

4. Types as values

A bare type name is a value too: it means “any value of this kind”. Unify it with a concrete value and the value wins, provided it fits:

port: integer
port: 8080

{ "port": 8080 }

And when it does not fit:

port: integer
port: "high"

→ fails with:

[aontu/no_scalar_unify]: Cannot unify values at path $.port

Cannot unify value: "high" with value: integer

The built-in kinds are string, boolean, top (the catch-all that admits anything), and the numeric family: number covers every numeric value, over its four leaves integer, float, biginteger and bigdecimal. Say number when you mean “some number”, and name a leaf when you mean that leaf: port: integer will not accept 8080.5. Your config now carries the first piece of its own schema.

5. Exact numbers with 0d

An ordinary aontu number, like an ordinary JSON number, is a binary floating-point value. For a port or a timeout that is fine. For money and for large identifiers it is not, because binary cannot represent every decimal:

total: 0.1 + 0.2

{ "total": 0.30000000000000004 }

Prefix the literals with 0d and the arithmetic is exact, stored as decimal digits and computed without rounding:

$ echo 'total: 0d0.1 + 0d0.2' | aontu
{
  "total": 0.3
}

The prefix is source syntax only; the digits generate as an ordinary JSON number. Whole digits give a biginteger (0d5); a decimal point or an exponent gives a bigdecimal (0d19.99, 0d1e3). Neither has a practical size limit.

The same care shows up as a refusal. A literal that cannot be stored without silently becoming a different number is not stored:

$ echo 'id: 9007199254740993' | aontu
[aontu/lossy_integer_literal]: Cannot resolve value at path $.id

This integer literal, 9007199254740993, is not exactly representable in
binary64, so storing it would silently round it to a DIFFERENT
number. aontu refuses rather than corrupts: write it as a `0d`
literal to get the exact integer.
...
$ echo $?
1

That value is 2^53+1, just past where doubles start skipping whole numbers. Take the hint:

$ echo 'id: 0d9007199254740993' | aontu
{
  "id": 9007199254740993
}

The exact ID survives to the output. One thing to remember when you write the schema: an exact value is a biginteger or a bigdecimal, never an integer, so constrain it with the exact leaf (or with number, which admits any numeric leaf).

6. Defaults with *

Mark a value as a default with *. A default is used only when nothing more specific is supplied:

port: *8080|integer

{ "port": 8080 }

The | is disjunction, a choice between alternatives (8080 or any integer), and the * says which branch to take when nothing forces the choice. Unify a concrete value on top and it wins:

port: *8080|integer
port: 9090

{ "port": 9090 }

Now try a float:

port: *8080|integer
port: 1.5

→ fails with:

[aontu/empty]: Cannot unify values at path $.port

Empty disjunction. The disjunction has no valid alternatives.

An override must be admitted by one of the branches you wrote, and 1.5 is a float, which integer does not admit: why the gate is this strict is argued in the explanation. When any number should be able to win, say so in the branch:

port: *8080|number
port: 1.5

{ "port": 1.5 }

*value | kind is the shape to remember: the kind you write is the kind you get. The full rule, including enums with defaults, is in the language reference.

7. References

Pull a value from elsewhere in the document with a path; $. starts at the root:

defaults: timeout: 30
service: timeout: $.defaults.timeout

{ "defaults": { "timeout": 30 }, "service": { "timeout": 30 } }

Change the default once and every reader of the path follows. A leading . is relative to the current object, and key() names the key a value is stored under: a compact way to give records their own name:

users: { alice:id:key() bob:id:key() }

{ "users": { "alice": { "id": "alice" }, "bob": { "id": "bob" } } }

Each record read its own key. The graph tutorial picks this thread up again, where names become identities.

8. Templates with &: (spread)

A &: entry inside a map is a template unified into every sibling key. Declare a shape once and it applies everywhere:

servers: { &: { region: *"us-east"|string active: *true|boolean } }
servers: web: region: "eu-west"
servers: db: {}

{
  "servers": {
    "db":  { "active": true, "region": "us-east" },
    "web": { "active": true, "region": "eu-west" }
  }
}

web overrode the default region; db supplied nothing and took both defaults; the template itself does not appear in the output. Spreads work in lists too ([&: {...}]), and §11 leans on that form.

9. Functions

aontu has a fixed set of built-in functions, and no user-defined ones. A few of the everyday ones:

web: region: "eu-west"
name: upper(mercury) # -> "MERCURY"
slug: lower(Mercury) # -> "mercury"
label: a + b + c # -> "abc"
copy: copy($.web) # deep copy of another node

{
  "copy":  { "region": "eu-west" },
  "label": "abc",
  "name":  "MERCURY",
  "slug":  "mercury",
  "web":   { "region": "eu-west" }
}

+ concatenates strings and adds numbers; upper and lower double as ceiling and floor on numbers, and they keep exact numbers exact (upper(0d1.1) prints 2.0, a bigdecimal ceiling). The rest are tabulated with tested examples in the language reference: bounds, pattern, and length constraints; generators that build children; and join, which folds a bag into a line of text. You need none of them today.

10. Sealing a shape with close

A map is open by default: unifying in extra keys is allowed, which is what lets separate files contribute separate keys. A schema usually wants the opposite. close() seals a shape:

point: close({ x:number y:number })
point: { x:1 y:2 }

{ "point": { "x": 1, "y": 2 } }

Add a key the shape does not declare and the document refuses:

point: close({ x:number y:number })
point: { x:1 y:2 z:3 }

→ fails with:

[aontu/closed]: Cannot resolve value at path $.point.z

Cannot add to closed structure.

open() reverses it. How open a schema should be is a genuine design dial; closed values has the mechanics, and the explanation the trade-off.

11. Putting it together

Time to spend all of it. Here is a single document that is schema, defaults and data at once: save it as config.aon:

# --- schema + defaults (could live in its own file) ---
service: close({
  name: string
  host: *localhost|string
  port: *8080|integer
  rate: *0d0.01|bigdecimal
  tags: [&: string]
})

# --- environment data merged on top ---
service: { name:api port:9090 rate:0d0.025 tags: [public http] }

Run it:

$ aontu config.aon
{
  "service": {
    "host": "localhost",
    "name": "api",
    "port": 9090,
    "rate": 0.025,
    "tags": [
      "public",
      "http"
    ]
  }
}

Every default is *value | kind, the §6 shape, so port: 9090.5 is refused rather than accepted. And tags says [&: string] where [string] might look like enough: a list literal is positional, so [string] constrains element 0 and waves tags: [public, 7] through; the explanation argues why it works that way.

With those two spellings in place the schema constrains every field. Defaults filled host, the data supplied the rest, rate stayed exact from 0d0.025 to the printed 0.025, and close kept stray keys out: one answer, assembled by unification, which would have failed loudly had anything conflicted.

12. Asking the document questions

You have written something that says quite a lot. From here on, stop reading it and start asking it. get prints one slice of the answer:

$ aontu model get '$.service.tags' config.aon
[
  "public",
  "http"
]

The path is the same $.-rooted path §7 used for references: quote it so the shell leaves the $ alone.

why is the verb to reach for when a value surprises you. It names every statement that contributed to a path, in source order:

$ aontu model why '$.service.port' config.aon
$.service.port = 9090
  1. *8080|integer  config.aon:5:9
  2. 9090  config.aon:11:26

Two contributions, and you wrote both: the default with its type, and the data that beat it, each with the line and column it came from. The first line is the whole written value: a contribution is a statement, so the 8080 and the integer arrive together, as the single value they were written as. Now ask about host, which nothing overrode:

$ aontu model why '$.service.host' config.aon
$.service.host = *"localhost"|string
  1. *"localhost"|string  config.aon:4:9

One contribution, still wearing its *: the default answered only because nothing outranked it. get has --keys, --types and --canon views as well, and the rest of the verb surface is tabulated in the API reference; query a path and explain a value put these two to work.

13. Validating data with aontu vet

Configuration rarely stays in one file: the schema is yours, the data arrives from somewhere else. Split config.aon at its comment. The schema half becomes service.aon:

service: close({
  name: string
  host: *localhost|string
  port: *8080|integer
  rate: *0d0.01|bigdecimal
  tags: [&: string]
})

and the data half becomes prod.aon:

service: { name:api port:9090 rate:0d0.025 tags: [public http] }

vet asks whether a data document holds against a schema document:

$ aontu vet service.aon prod.aon
verdict: valid

Now a second environment arrives, staging.aon, written by someone else:

service: { name:search port:8100 tags: [internal 3] }

Vet it:

$ aontu vet service.aon staging.aon
verdict: invalid

$.service.tags.1: no_scalar_unify [conflict]
  [aontu/no_scalar_unify]: Cannot unify values at path $.service.tags.1
  data: staging.aon:1:50 (3)
  schema: service.aon:6:13 (string)
$ echo $?
1

Read the finding from the top. The path $.service.tags.1 is exactly where the trouble is: element 1 of the list, the 3. Then two sites, because a conflict is always between two statements and neither one owns the blame: data is what arrived (3, line 4, column 20 of staging.aon) and schema is what it had to meet (string, line 6, column 16 of service.aon). Every finding is sited on both sides, so you never guess which file to open. And the exit code, 1, is the verdict class: a CI job needs nothing else.

The 3 was meant to be a tier name. Write staging.aon again, saying so:

service: { name:search port:8100 tags: [internal tier3] }
$ aontu vet service.aon staging.aon
verdict: valid

Notice staging.aon never mentions host or rate and passes anyway: the schema’s defaults stand in. To see what the service actually gets, unify the two files: a document that loads both is all it takes. Write stack.aon:

@"./service.aon"
@"./staging.aon"
$ aontu stack.aon
{
  "service": {
    "host": "localhost",
    "name": "search",
    "port": 8100,
    "rate": 0.01,
    "tags": [
      "internal",
      "tier3"
    ]
  }
}

@"file" loads a source file and unifies it in place, which is all “multi-file” means here; split a model across files grows the idea into versioned, vendored dependencies.

vet has a third verdict, and it is the one that stops the loop calling an unfinished document valid. Delete the name line from staging.aon:

service: { port:8100 tags: [internal tier3] }
$ aontu vet service.aon staging.aon
verdict: incomplete

$.service.name: mapval_required [incomplete]
  [aontu/mapval_required]: Cannot resolve value at path $.service.name
  schema: service.aon:2:9 (string)
$ echo $?
3

Nothing contradicts anything; the document is simply not finished yet. aontu has kept “wrong” and “unfinished” apart all through this page, and vet keeps them apart in its exit codes too: one code per verdict class, which is what makes it usable as a gate. The codes, the JSON and SARIF report forms and --watch are specified under aontu vet, and validate in CI is the recipe.

That is the loop the whole verb surface exists for: emit a document, vet it against the truth it has to satisfy, and when it fails, let the two sites and aontu model why say where to repair it. You have now run it once by hand, which matters, because repair is the step you will eventually hand to a command: aontu model set rewrites overlay files under exactly these rules, and change a pinned value shows what it will and will not touch.

Where to go next

Your config is a tree. Real systems are graphs: services that depend on each other, ownership that must not cycle, schemas that contain themselves. The graph tutorial is the second half of this one: identity, relations, reachability and recursion, on the engine you just used. When the document is ready to leave your repository, share a model as a package pins it for whoever imports it, and from a model to a file tree computes source files from it. The tutorials index lists all four.

For a task you already have, go straight to its guide:

Every rule and edge case is in the language reference; the command and both APIs are in the API reference; the reasons are in the explanation.