How-to › Model, configure and generate

How to explain why a unified config has a given value#

Trace one field of a config unified from base, team, and environment files back to the line that set it, and fail CI when a field still rests on a default.

Audience
Platform team
Level
intermediate
Topic
Unify configuration with a spec language
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
aontu model whyA config unified from layers, where you need every contributing file and lineOne more tool in the chain, and a list that includes the defaults that lostThe config already lives in another configuration language and stays there
CUE cue eval -e and cue defThe config is CUE, and you need the value or whether it is a defaultNo file positions from the CLI for a value that unified cleanly, only for a conflictYou need the source of a value nobody disputes
Jsonnet std.traceJsonnet configs you can edit to add a trace callYou change source to ask, and a trace in an overridden field never runsThe layers are shared and you cannot edit them to debug
Nickel nickel queryNickel configs where the contract, priority, and default matterReports metadata and the final value, never the file that merged it inYou 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.

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

  1. 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

  2. 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 *8080 a later addition to a formalism built for grammars. ↩︎ Back to text

  3. Nickel’s manual page on merging treats a default as a priority. default is the lowest, force is 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.