Production listens on port 9090, and the port has to move. base.aontu says
port: *8080 | integer and prod.aontu says port: $.ports.public. The number 9090 sits in a
third file under a different key. Finding it means commenting layers out one at a time and
re-running the build until the value changes.
What you get
You will end up with the file and line behind any value in a layered aontu config, plus a CI check that fails when an environment field still rests on a default. This is for you if a config value has surprised you.
Short answer
Run aontu model why '$.server.port' config.aontu. It prints every contribution to the value at
that path, with the file, line, and column each was written on, and it follows a reference to the
literal it reaches. A default such as *8080 | integer stays in that list after another file
pins the value, so the line that decided is the one whose value matches the result.
You will need
Node 22 or later, and aontu installed from npm beside the config. Verified 2026-10-15 against
Node 22.22.0 and aontu 0.76.0. The aontu package declares engines.node as >=24, so npm
prints an EBADENGINE warning under Node 22 and installs it anyway; every command here ran under
22. If a contradiction report is unfamiliar, read the page on reading a config contradiction
error before this one.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
aontu model why | A config unified from layers, where you need every contributing file and line | One more tool in the chain, and a list that includes the defaults that lost | The config already lives in another configuration language and stays there |
CUE cue eval -e and cue def | The config is CUE, and you need the value or whether it is a default | No file positions from the CLI for a value that unified cleanly, only for a conflict | You need the source of a value nobody disputes |
Jsonnet std.trace | Jsonnet configs you can edit to add a trace call | You change source to ask, and a trace in an overridden field never runs | The layers are shared and you cannot edit them to debug |
Nickel nickel query | Nickel configs where the contract, priority, and default matter | Reports metadata and the final value, never the file that merged it in | You need a file and a line, not a contract |
CUE and Nickel both say whether a value is a default, but only a conflict makes either name a file. Jsonnet says nothing until you edit a layer, and then only about the line you edited. aontu names every contributing file and line, at the price of one more tool and a list you read rather than skim. The alternative cost is the afternoon spent bisecting layers by commenting them out.
Voxgig maintains aontu.1 It is the subject of this page, and the CUE, Nickel, and Jsonnet results below were run outside the page’s checked samples.
Layer the config
Three layers make the example. The base sets defaults for every service, the team file sets the payments team’s ports and timeout, and production points the port at the team’s public port.
# Organization-wide defaults. Every service starts here.
server: {
port: *8080 | integer
host: *"0.0.0.0" | string
timeout: integer & min(1)
}
log: level: *"info" | "debug" | "warn" | "error"
# The payments team's settings, shared by every environment.
ports: { public: 9090, admin: 9091 }
server: timeout: 30
log: level: "warn"
# Production. The public port comes from the team's port table.
server: port: $.ports.public
# The production config: three layers, unified in any order.
@"./base.aontu"
@"./team.aontu"
@"./prod.aontu"
An @"..." include unifies a file in rather than pasting it, so order carries no meaning. A
layer cannot override another layer, and two layers that pin different values are a
contradiction. Evaluate the entry file:
npx aontu config.aontu
{
"log": {
"level": "warn"
},
"ports": {
"admin": 9091,
"public": 9090
},
"server": {
"host": "0.0.0.0",
"port": 9090,
"timeout": 30
}
}
Read base.aontu first and *8080 looks like the answer. It is not.
Ask which line decided
aontu model why takes a path and the entry file and prints each contribution to the value at
that path. The CLI prints absolute paths, and the sed strips the working directory so the
output reads the same on any machine.
npx aontu model why '$.server.port' config.aontu | sed "s#$(pwd)/##"
$.server.port = 9090
1. *8080|integer base.aontu:3:9
2. $.ports.public prod.aontu:2:15 (ref)
3. 9090 team.aontu:2:18
Three contributions met at that path. The default from base.aontu is one of them, and it lost:
a concrete 9090 meets *8080 | integer and wins, so the default never applies. The reference
in prod.aontu carries the role ref, and why follows it to the literal it reached, in
team.aontu on line 2. That is the line to edit.
The reference documentation lists contributions in source order: by file, then line, then column. Reversing the three includes prints the same list. So the first line is not the first layer, and the last line is not the winner. The winner is the line whose value matches the value printed at the top.
Keep the model in the command. The same reference documents a bare aontu why <pkg> as a
package verb, which asks why a package is in the dependency closure, and it reads a config path
as a package name:
npx aontu why '$.server.port' config.aontu
verdict: missing
$.server.port: not in the closure
It exits 1, the code model why uses for a path that names nothing. A script that checks the
exit code reads the wrong verb as a missing field rather than as a usage error.
Tell a default from a pin
A field no file set prints in the generated JSON exactly like a field a file pinned. why keeps
them apart, in the value it reports and in the list of what met there.
npx aontu model why '$.server.host' config.aontu | sed "s#$(pwd)/##"
$.server.host = *"0.0.0.0"|string
1. *"0.0.0.0"|string base.aontu:4:9
The generated config says "0.0.0.0". The why record says *"0.0.0.0"|string, a disjunction
with a default, and one contribution. Production binds to every interface because no file said
otherwise, which is a different fact from a file saying so.
The marker alone does not settle it. A layer that writes the default’s own value, as keep.aontu
does with server: host: "0.0.0.0", leaves aontu 0.76.0 reporting a value that is still marked:
npx aontu model why '$.server.host' config-keep.aontu | sed "s#$(pwd)/##"
$.server.host = *"0.0.0.0"|"0.0.0.0"
1. *"0.0.0.0"|string base.aontu:4:9
2. "0.0.0.0" keep.aontu:2:15
The second contribution is what changed: a file wrote the value that generates. A check that reads only the marker would fail a production layer that chose the default on purpose, and keep failing after someone pinned it.
Check it worked
pinned.mjs turns that difference into a CI check. It calls why from the aontu library, and
get when the value is still open, and sorts each path into one of four states.
// Where the value at one path came from, as one of four states:
// pinned a file wrote this exact value
// computed the value is concrete, but no file wrote it as written (arithmetic, a string join)
// default no file chose a value, so generation prints the default
// incomplete no value at all, only a kind or a constraint, so generation fails
export function explain(file, path, { cwd = process.cwd() } = {}) {
const entry = resolve(cwd, file)
const src = readFileSync(entry, 'utf8')
const opts = { path: entry }
const traced = why(src, path, opts)
if (!traced.ok) return { path, state: 'error', error: describe(traced.findings[0]) }
const { value, conjuncts } = traced.record
const sources = conjuncts.map((c) => ({
canon: c.canon,
role: c.role,
at: `${relative(cwd, c.site.file)}:${c.site.row}:${c.site.col}`,
}))
if (isConcrete(value)) {
// A reference is never the pin: the value it points at is, and why lists that too.
const pinnedBy = sources.filter((s) => s.role !== 'ref' && s.canon === value)
return { path, value, state: pinnedBy.length ? 'pinned' : 'computed', pinnedBy, sources }
}
const generated = get(src, path, opts)
if (!generated.ok) return { path, value, state: 'incomplete', pinnedBy: [], sources, error: describe(generated.findings[0]) }
// A file that pins the default's own value leaves the marker on (*8080|8080), so the
// marker alone does not mean no file chose: look for a written value equal to the result.
const pinnedBy = sources.filter((s) => s.role !== 'ref' && s.canon === generated.out)
return { path, value: generated.out, state: pinnedBy.length ? 'pinned' : 'default', pinnedBy, sources }
}
Give it the fields production must decide for itself. It exits 1 if any of them is a default, incomplete, misspelled, or in conflict.
node pinned.mjs config.aontu '$.server.port' '$.server.host' '$.log.level' '$.server.timeout'
$.server.port = 9090
pinned by team.aontu:2:18
also met base.aontu:3:9 *8080|integer
also met prod.aontu:2:15 $.ports.public (ref)
$.server.host = "0.0.0.0"
DEFAULT no file pins it, so it is the default written at base.aontu:4:9
$.log.level = "warn"
pinned by team.aontu:4:13
also met base.aontu:7:13 ((*"info"|"debug")|"warn")|"error"
$.server.timeout = 30
pinned by team.aontu:3:18
also met base.aontu:5:12 integer
also met base.aontu:5:22 min(1)
3 of 4 paths pinned or computed
The host fails, which is what the check is for. The suite pins each state, and the case that matters most is a pin that disappears:
test('dropping the production layer falls back to the default without an error', () => {
const t = layers('@"./base.aontu"\n@"./team.aontu"\n')
try {
const r = explain('entry.aontu', '$.server.port', { cwd: t.dir })
assert.equal(r.state, 'default')
assert.equal(r.value, '8080')
} finally {
t.done()
}
})
node --test pinned.test.mjs
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 629.302009
When it goes wrong
A deleted pin leaves the build passing, because the default fills the gap. Delete the production
layer, or its one line, and the port falls back to 8080. Unification has nothing to refuse, so
aontu config.aontu succeeds, and only a check like pinned.mjs notices.
Two layers pin different values. why exits 4, because a document that does not unify has no
value to explain. Evaluate it instead, and read the two sites the contradiction names:
npx aontu config-hotfix.aontu
Cannot unify value: 9090 with value: 9443
--> team.aontu:2:18
1 | # The payments team's settings, shared by every environment.
2 | ports: { public: 9090, admin: 9091 }
^ value was: 9090
3 | server: timeout: 30
4 | log: level: "warn"
Cannot unify value: 9443 with value: 9090
--> hotfix.aontu:2:15
1 | # Someone's quick fix, committed beside the production layer.
2 | server: port: 9443
^ value was: 9443
The report names team.aontu, not prod.aontu, because that is where the conflicting 9090 was
written. Look only in the production layer and you look in the wrong file.
why exits 1 with a no_path finding. The path names nothing in the document, which is
usually a misspelled key. pinned.mjs reports it as an error rather than as a default.
Ask the other three languages
The same three layers were written in each language and run once, locally, on cue v0.17.1 from
go install, Nickel 1.18.0 from cargo install, and libjsonnet compiled to WebAssembly in the
jsonnet-js 0.17.0 package. The page’s package.json installs none of them, so these results are
described rather than captured.
cue eval -e server.port .
cue def -e server.port .
cue eval printed 9090 and nothing else. cue def printed (*8080 | int) & 9090, which
shows a default that lost to a pin, without saying which file held either.2 Positions appeared
only when a fourth file pinned 9443: the conflict error listed base.cue, prod.cue and
team.cue by line and column. So the way to make CUE name a file is to contradict the value on
purpose.
nickel query --field server.port --format json base.ncl team.ncl prod.ncl
nickel query printed the contract Number, the priority 0, and the value 9090. With the
production file left out it printed default: 8080 instead. A default shows up as metadata,
then, but the answer never names the file that set the value.3
std.trace prints a message and the line of its own call to stderr. Placed in a base layer
whose port a later layer overrides, it printed nothing, because Jsonnet evaluates fields lazily
and never evaluated the overridden one. A trace answers for the line it sits on, so it finds the
deciding layer only once you have guessed it.
When not to do this
Do not move a config into aontu to get model why. If the config is CUE, Nickel, or Jsonnet,
contradicting a CUE value or reading nickel query metadata costs an hour, and a migration costs
a quarter.
Do not treat why as an audit trail. It explains what the files on disk say, not who changed
them or when; git blame on the line it names answers that.
Do not list every field in pinned.mjs. A base default exists so that most fields need no pin,
and a check that demands one everywhere turns defaults into boilerplate. List the fields an
environment must decide for itself.
Do not run why over layers you did not write without confining includes. aontu resolves
@"..." through the filesystem by default, and its trust contract
documents the --trust setting that limits where an include may read.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0 and aontu 0.76.0, installed from npm in the sample
directory. Every output block is what the command preceding it printed. The CUE, Nickel, and
Jsonnet results were run locally against cue v0.17.1, Nickel 1.18.0, and jsonnet-js 0.17.0, and
the page’s checks do not re-run them.
Footnotes
-
The name is Irish. Ó Dónaill’s dictionary glosses aontú as “assent, agreement” and files it as the verbal noun of aontaigh. The phrases in the same entry run from the United Irishmen to the United Nations to consenting to sin. A config engine needs the first sense only, and reports a contradiction when two files withhold it. ↩︎ Back to text
-
CUE’s account of its own logic takes its title from Bob Carpenter’s The Logic of Typed Feature Structures, published in 1992. It credits most of its inspiration to the Lingo and LKB project. A footnote there adds that typed feature structures typically have no default values, and that the structure alone does not work quite as well if one needs numeric values. That, the page says, is why CUE adds defaults, which makes
*8080a later addition to a formalism built for grammars. ↩︎ Back to text -
Nickel’s manual page on merging treats a default as a priority.
defaultis the lowest,forceis the highest, and a plain value sits at priority 0 between them, where the higher of two priorities “simply erases” the lower. Unification erases nothing, which is why two aontu files that disagree produce an error instead of a winner. ↩︎ Back to text