# How to vet an aontu model for contradictions before you generate

> Run aontu vet over every environment overlay in CI, so two files that disagree fail in seconds with both lines named, and refuse a release that rests on defaults.

Source: https://voxgig.com/howto/vet-a-model-file-before-you-generate

- Audience: platform-team
- Level: advanced
- Verified: 2026-10-21
- Published: 2026-10-17

## Short answer

Run `aontu vet model.aontu <overlay>.aontu` for each environment overlay in the CI job that lints your OpenAPI document, and fail the job on any non-zero exit. Exit 1 is a contradiction, and the report names both files and lines that disagree. Exit 3 means a field has no value yet. Add `--coverage` to list the declarations no overlay met, where a default such as `*8080 | integer` hides, and vet releases against the model with its defaults removed.

---
## You will need

Node 22 or later, aontu installed from npm beside the model files, one overlay per environment,
and a CI job that already lints the OpenAPI document. Verified 2026-10-21 against Node 22.22.0,
aontu 0.76.0, and Ajv 8.20.0. The aontu package declares `engines.node` as 24 or later, so npm
prints an `EBADENGINE` warning under Node 22 and installs it anyway; every command here ran under
22. The sample models one service with a shared base file and two overlays; the generator itself
is out of scope.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [aontu vet](https://aontu.dev/docs/reference-api#aontu-vet) | The generator reads aontu files and you want a contradiction named by both files and lines | Another language in the build that contributors must learn, and an include per overlay so vet sees one document | The config is CUE already, or one plain JSON file that nobody layers |
| [cue vet](https://cuelang.org/docs/reference/command/cue-help-vet/) | The model is CUE, and you want the same unification semantics from a Go binary with no Node | A Go toolchain or a release download, and defaults that fill in under `export` just as they do in aontu | The model files are aontu or JSON Schema and a second unifier buys nothing |
| [JSON Schema with Ajv](https://ajv.js.org/guide/getting-started.html) | The config is plain JSON merged by your own code, and every tool in the chain already speaks JSON Schema | A merge that settles disagreements before validation, so a report names a path and never the file that set it | Two files can legitimately set the same field and you need to know when they differ |

Voxgig maintains aontu. This page compares it with `cue vet` and JSON Schema validation with Ajv.

cue and aontu make the same promise: two files that pin different values fail instead of one
winning, and both report the positions of both values.[^1] The difference is the toolchain, a Go
binary against an npm package with a Node start on every run. Ajv costs the least to adopt,
because every tool already produces or consumes JSON Schema. It pays for that when two overlays
disagree: the merge picks a winner before the validator runs, so the disagreement is settled
before anything could report it.

## Vet each overlay against the model

The model declares what every generated client config must satisfy. Two fields carry defaults,
marked with the asterisk, so a config that leaves them out still generates.

```aontu title="model.aontu"
service: {
  name: string & re("^[a-z][a-z0-9-]*$")
  base_url: string
  port: *8080 | integer
  timeout_ms: integer & min(100)
  retries: *3 | integer
}
```

The base file holds what every environment shares, and each overlay includes it. The include
matters: vet treats each data file it is handed as a separate candidate, so the layers have to
meet inside one document for a contradiction between them to exist.

```aontu title="base.aontu"
service: {
  name: "billing"
  timeout_ms: 5000
}
```

```aontu title="staging.aontu"
@"./base.aontu"
service: {
  base_url: "https://staging.example.com"
  timeout_ms: 2000
}
```

Production sets `base_url` and nothing else, and passes:

```bash
npx aontu vet model.aontu prod.aontu
```

```text output
verdict: valid
```

Staging lowered a timeout that `base.aontu` also sets. Unification has no notion of a later file
winning, so the two values are a contradiction, and the report names both:

```bash
npx aontu vet model.aontu staging.aontu
```

```text output
verdict: invalid

$.service.timeout_ms: scalar_value [conflict]
  [aontu/scalar_value]: Cannot unify values at path $.service.timeout_ms
  data: base.aontu:4:15 (5000)
  data: staging.aontu:5:15 (2000)
```

Exit code 1. The two `data:` lines are the trace: the file, line, and column each value was
written at. The exit codes are verdict classes rather than a pass or fail bit.[^2] 0 is valid, 1 is
a contradiction or a file that will not parse, 3 is incomplete, and 4 means the schema itself is
unusable.

## Fail the CI job on the worst verdict

A script runs vet once per overlay with `--format json`, prints one line per environment, and
exits with the highest code it saw. Handed no overlay at all, it exits 2. Put it in the job that
lints the OpenAPI document, as a step after the lint.

```js title="vet-all.mjs"
const [schema, ...overlays] = process.argv.slice(2)
if (!schema || overlays.length === 0) {
  // With no overlays there is nothing to vet, so the step fails rather than pass on an empty list.
  console.error('usage: node vet-all.mjs <schema> <overlay>... (no overlays given)')
  process.exit(2)
}
let worst = 0

for (const file of overlays) {
  const r = spawnSync('node_modules/.bin/aontu', ['vet', '--format', 'json', schema, file], { encoding: 'utf8' })
  let report
  try {
    report = JSON.parse(r.stdout)
  } catch {
    console.error(r.stderr || r.stdout)
    process.exit(2)
  }
  const name = file.replace(/\.aontu$/, '').padEnd(9)
  if (r.status === 0) console.log(`${name}${report.verdict}`)
  for (const f of report.findings ?? []) {
    // Every site a finding names: the file, line, and column each value was written at.
    const sites = f.sites.map((s) => `${s.role} ${s.file}:${s.row}:${s.col} (${s.src})`).join('  ')
    console.log(`${name}${report.verdict}  ${f.path}  ${sites}`)
  }
  worst = Math.max(worst, r.status)
}
process.exit(worst)
```

```bash
node vet-all.mjs model.aontu prod.aontu staging.aontu
```

```text output
prod     valid
staging  invalid  $.service.timeout_ms  data base.aontu:4:15 (5000)  data staging.aontu:5:15 (2000)
```

The step exits 1 and the job fails in the time it takes Node to start, because a `run` step
[takes its status from the exit code](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsshell)
of the last command. The JSON report carries the same sites as the text, so an annotation has its
file and line.

## Refuse a release that rests on defaults

A default is a value. Production never set `port`, vet called it valid, and the generator will
write 8080. `--coverage` says which declarations no data met:

```bash
npx aontu vet --coverage model.aontu prod.aontu
```

```text output
verdict: valid

coverage: 3/3 data leaves checked, 6 schema declarations
  unused: $.service.port
  unused: $.service.retries
```

The two unused declarations are the two with defaults. Coverage is a report, not a gate, so the
gate is a second schema: the same fields with the asterisks removed, which a release
is vetted against.

```aontu title="release.aontu"
service: {
  name: string & re("^[a-z][a-z0-9-]*$")
  base_url: string
  port: integer
  timeout_ms: integer & min(100)
  retries: integer
}
```

```bash
npx aontu vet release.aontu prod.aontu
```

```text output
verdict: incomplete

$.service.port: mapval_no_gen [incomplete]
  [aontu/mapval_no_gen]: Cannot resolve value at path $.service.port
  schema: release.aontu:6:9 (integer)
```

Exit code 3. Nothing contradicts, and the model is not satisfied either, which is the verdict a
placeholder deserves. vet names one unset field per run, so work from the `--coverage` list. Pin
`port` and `retries` in `prod.aontu`, and the release vets valid while the generation model keeps
its defaults for every other environment.

## Export the schema for tools that want JSON Schema

Other tools in the chain, from a mock server to an editor, read only JSON Schema.
[`aontu jsonschema`](https://aontu.dev/docs/reference-api#aontu-jsonschema) exports the model as
draft 2020-12 to stdout and reports anything it could not carry on stderr. The default becomes
an `anyOf` with the constant first, plus a `default` keyword:

```bash
npx aontu jsonschema model.aontu
```

```text output
        "port": {
          "anyOf": [
            {
              "const": 8080,
              "type": "integer"
            },
            {
              "type": "integer"
            }
          ],
          "default": 8080
        },
```

`schema.json` in the sample is that output saved. Ajv validates a plain JSON config against it,
after the config has been merged from `config/base.json` and `config/staging.json` the way a
loader would:

```js title="validate.mjs"
export function merge(base, overlay) {
  return { service: { ...base.service, ...overlay.service } }
}

export function check(config, options = {}) {
  const validate = new Ajv2020({ allErrors: true, ...options }).compile(schema)
  const ok = validate(config)
  return { ok, errors: (validate.errors ?? []).map((e) => `${e.instancePath} ${e.message}`) }
}
```

```bash
node validate.mjs
```

```text output
timeout_ms after the merge: 2000 (base.json said 5000)
valid: false
  /service must have required property 'port'
  /service must have required property 'retries'
valid with useDefaults: true, port 8080, retries 3
```

The merge kept 2000 and dropped 5000 before Ajv saw either, so the disagreement that failed
`staging.aontu` is not an error here. The exported schema lists
`port` and `retries` as required, because after unification every field has a value, while JSON
Schema's `default` is an annotation that a validator does not apply.[^3] And
[`useDefaults`](https://ajv.js.org/guide/modifying-data.html) writes 8080 and 3 into the config
and reports it valid, which is the same placeholder one layer down. Each
[error object](https://ajv.js.org/api.html#validation-errors) carries an `instancePath` into the
merged document and no file.

## Check it worked

Six tests run the CLI and the Ajv script and pin each verdict. The one that carries the page
asserts the two sites of the contradiction:

```js title="vet.test.mjs"
test('a contradiction is exit 1 and the report names both files and lines', () => {
  const r = vet('model.aontu', 'staging.aontu')
  assert.equal(r.status, 1)
  assert.equal(r.verdict, 'invalid')
  assert.equal(r.findings.length, 1)
  assert.equal(r.findings[0].path, '$.service.timeout_ms')
  const sites = r.findings[0].sites.map((s) => `${s.file}:${s.row}:${s.col}=${s.src}`)
  assert.deepEqual(sites, ['base.aontu:4:15=5000', 'staging.aontu:5:15=2000'])
})
```

```bash
node --test vet.test.mjs
```

```text output
1..6
# tests 6
# suites 0
# pass 6
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 3169.979343
```

`# pass 6` covers the valid overlay, the contradiction, the incomplete release, the generated
default, the script's exit code, and the Ajv run.

## When it goes wrong

The generated client listens on 8080, a port that no overlay set. Evaluate what the generator
evaluates, the model unified with the overlay, and the default is in the output:

```bash
npx aontu config-prod.aontu
```

```text output
{
  "service": {
    "base_url": "https://api.example.com",
    "name": "billing",
    "port": 8080,
    "retries": 3,
    "timeout_ms": 5000
  }
}
```

`model why` lists one contribution to that path, which is the default in the model. The `sed`
strips the working directory from the one absolute path aontu prints:

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

```text output
$.service.port = *8080|integer
  1. *8080|integer  model.aontu:5:9
```

The fix is the release schema from the preceding section, in the CI step, for the environments
that must decide their own ports.

vet reports `incomplete` for a field the overlay sets. The command handed vet two data files,
`base.aontu` and the overlay, and vet checked each on its own:

```bash
npx aontu vet model.aontu base.aontu prod.aontu
```

```text output
verdict: incomplete

$.service.base_url: mapval_no_gen [incomplete]
  [aontu/mapval_no_gen]: Cannot resolve value at path $.service.base_url
  schema: model.aontu:4:13 (string)
```

The finding is about `base.aontu` vetted alone, which sets no `base_url`. Pass one overlay per
run and let its include bring the base in.

The CI script passes a model with an unset field. A shell step that tests for exit code 1 lets
exit 3 through, and `incomplete` is the verdict a missing pin gets. Test for any non-zero code,
as `vet-all.mjs` does by exiting with the highest status it saw.

## When not to do this

Do not move a config into aontu to get this check. If the config is CUE, `cue vet` already
refuses a contradiction and names both positions. If it is one JSON file written by one team, Ajv
against a schema catches every bad value a unifier would. aontu earns its place when several
files meet in one document and the question is which two of them disagree.

Do not strip the defaults out of the generation model to make the release gate strict. Every
other environment then has to pin every field, which nobody maintains for long. Keep the defaults where generation reads them and vet releases against
the copy without them.

Do not treat `--coverage` as the gate. It reports what a run examined, and `--strict-coverage`
fails only a run that examined nothing, so a default that no overlay touched still vets valid.
The release schema is the check that fails.

Do not vet the generated output instead of the model. By then the placeholder is a constant in
source code, and the generator has already spent the four minutes this check exists to save.

## Related how-tos

- [Explain why a unified config has a given value](/howto/explain-why-a-unified-config-value)

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

- [Enforce a naming convention across 40 microservice specs](/howto/enforce-naming-conventions-across-many-specs)

- [Gate a pull request on OpenAPI lint results](/howto/gate-a-pull-request-on-openapi-lint)

## Last verified

Verified 2026-10-21 against Node 22.22.0, aontu 0.76.0, and Ajv 8.20.0, installed from npm in the
page's code directory, with aontu warning about its `engines.node` field and running under 22
regardless. Every output block is what the command preceding it printed; the `model why` block
is piped through `sed` to strip the working directory. The CUE comparison ran locally against cue
v0.17.1 from `go install`, on the same model written in CUE, and is described rather than
captured. `cue vet` printed `conflicting values 2000 and 5000` with both positions, `cue export`
filled the two defaults, and against the model without defaults `cue vet -c` reported two
incomplete values.

[^1]: The CUE [specification](https://cuelang.org/docs/reference/spec/) introduces defaults
through disjunctions. Any element may be marked by prefixing it with an asterisk, and a marked
disjunction resolves to its default when nothing else pins the value. aontu writes the same
asterisk, `*8080 | integer`, and resolves it the same way, so a reader moving between the two
languages keeps one habit.

[^2]: The [reference](https://aontu.dev/docs/reference-api#aontu-vet) describes exit 4 as never
the fault of the data, and assigns exit 2 to a bad option or an unreadable file. The verdict classes
exist, it says, because the three ways to fail call for three different responses. Repair what
you emitted, finish what you started, or stop, because the truth you were handed is unusable.
Few build tools distinguish the third from the first.

[^3]: The validation vocabulary of
[JSON Schema 2020-12](https://json-schema.org/draft/2020-12/json-schema-validation#section-9.2)
files `default` under basic meta-data annotations, beside `title` and `description`. The keyword
can be used to supply a default value, the text says, and the specification recommends, without
requiring, that the value be valid against its own schema. A validator is free to ignore it,
which is why Ajv needs an option to apply one and documents that option under
[modifying data](https://ajv.js.org/guide/modifying-data.html).