Generation runs for four minutes and then fails, or finishes with a value nobody chose. Two of your environment overlays disagree about a timeout, and the generator took whichever file it read last. The OpenAPI document passed lint, because the lint job never opens the model files that drive the generator, and the port in the generated client is a default no overlay set.
What you get
You will end up with a CI step that vets every overlay against the model in seconds, names both files of a contradiction, and refuses a release that rests on defaults. This is for you if a generator reads aontu files beside your OpenAPI document.
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 | 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 | 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 | 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.
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.
service: {
name: "billing"
timeout_ms: 5000
}
@"./base.aontu"
service: {
base_url: "https://staging.example.com"
timeout_ms: 2000
}
Production sets base_url and nothing else, and passes:
npx aontu vet model.aontu prod.aontu
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:
npx aontu vet model.aontu staging.aontu
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.
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)
node vet-all.mjs model.aontu prod.aontu staging.aontu
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
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:
npx aontu vet --coverage model.aontu prod.aontu
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.
service: {
name: string & re("^[a-z][a-z0-9-]*$")
base_url: string
port: integer
timeout_ms: integer & min(100)
retries: integer
}
npx aontu vet release.aontu prod.aontu
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 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:
npx aontu jsonschema model.aontu
"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:
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}`) }
}
node validate.mjs
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 writes 8080 and 3 into the config
and reports it valid, which is the same placeholder one layer down. Each
error object 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:
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'])
})
node --test vet.test.mjs
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:
npx aontu config-prod.aontu
{
"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:
npx aontu model why '$.service.port' config-prod.aontu | sed "s#$(pwd)/##"
$.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:
npx aontu vet model.aontu base.aontu prod.aontu
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
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.
Footnotes
-
The CUE specification 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. ↩︎ Back to text -
The reference 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. ↩︎ Back to text
-
The validation vocabulary of JSON Schema 2020-12 files
defaultunder basic meta-data annotations, besidetitleanddescription. 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. ↩︎ Back to text