How-to › Describe your API

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.

Audience
Platform team
Level
advanced
Topic
Lint and govern API specs
Verified

Read first: Gate a pull request on OpenAPI lint results

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
aontu vetThe generator reads aontu files and you want a contradiction named by both files and linesAnother language in the build that contributors must learn, and an include per overlay so vet sees one documentThe config is CUE already, or one plain JSON file that nobody layers
cue vetThe model is CUE, and you want the same unification semantics from a Go binary with no NodeA Go toolchain or a release download, and defaults that fill in under export just as they do in aontuThe model files are aontu or JSON Schema and a second unifier buys nothing
JSON Schema with AjvThe config is plain JSON merged by your own code, and every tool in the chain already speaks JSON SchemaA merge that settles disagreements before validation, so a report names a path and never the file that set itTwo 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.

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

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

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

  3. The validation vocabulary of JSON Schema 2020-12 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. ↩︎ 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.