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

Source: https://voxgig.com/howto/explain-why-a-unified-config-value

- Audience: platform-team
- Level: intermediate
- Verified: 2026-10-15
- Published: 2026-10-15

## 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`](https://aontu.dev/docs/reference-api/) | 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`](https://cuelang.org/docs/reference/command/cue-help-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`](https://jsonnet.org/ref/stdlib.html) | 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`](https://nickel-lang.org/user-manual/command-line-interface/) | 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.

```aontu title="base.aontu"
# 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"
```

```aontu title="team.aontu"
# The payments team's settings, shared by every environment.
ports: { public: 9090, admin: 9091 }
server: timeout: 30
log: level: "warn"
```

```aontu title="prod.aontu"
# Production. The public port comes from the team's port table.
server: port: $.ports.public
```

```aontu title="config.aontu"
# 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:

```bash
npx aontu config.aontu
```

```text output
{
  "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.

```bash
npx aontu model why '$.server.port' config.aontu | sed "s#$(pwd)/##"
```

```text output
$.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](https://aontu.dev/docs/reference-api/) 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:

```bash
npx aontu why '$.server.port' config.aontu
```

```text output
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.

```bash
npx aontu model why '$.server.host' config.aontu | sed "s#$(pwd)/##"
```

```text output
$.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:

```bash
npx aontu model why '$.server.host' config-keep.aontu | sed "s#$(pwd)/##"
```

```text output
$.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.

```js title="pinned.mjs"
// 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.

```bash
node pinned.mjs config.aontu '$.server.port' '$.server.host' '$.log.level' '$.server.timeout'
```

```text output
$.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:

```js title="pinned.test.mjs"
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()
  }
})
```

```bash
node --test pinned.test.mjs
```

```text output
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:

```bash
npx aontu config-hotfix.aontu
```

```text output
 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.

```bash
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.

```bash
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](https://aontu.dev/docs/trust/)
documents the `--trust` setting that limits where an include may read.

## Related how-tos

- [Merge nested config objects with predictable precedence](/howto/merge-nested-config-objects-with-precedence)

- [Let a coding agent edit a config spec without breaking it](/howto/let-an-agent-edit-a-config-spec)

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

[^1]: The name is Irish. [Ó Dónaill's dictionary](https://www.teanglann.ie/en/fgb/aont%C3%BA)
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.

[^2]: CUE's [account of its own logic](https://cuelang.org/docs/concept/the-logic-of-cue/)
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.

[^3]: Nickel's [manual page on merging](https://nickel-lang.org/user-manual/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.