The agent adds a service on port 80, aontu vet refuses it, and the agent’s next edit deletes
min(1024) from the spec. Every check passes after that, because each one validates the data
against whatever spec it is handed. The constraint that kept services off privileged ports is
gone, in a one-line hunk inside a forty-file change.
What you get
You will end up with a check that refuses an edit which loosens the spec, wired to run after every agent edit and again at commit. This is for you if an agent edits the spec as well as the data it governs.
Short answer
Keep a directory of counterexamples, instances the spec must refuse, each naming the path the refusal must come from, and run them in the same check that vets the data. Run that check from a Claude Code PostToolUse hook and from pre-commit. When an agent deletes a constraint to clear an error, vet passes, and a counterexample passes with it, which is what the check reports.
You will need
Node 22 or later, aontu installed from npm, and an agent session that runs hooks or reaches an MCP server. Verified 2026-10-15 against Node 22.22.0 and aontu 0.76.0. Wiring the aontu MCP server and language server into a client is a task of its own, so this page assumes both are connected. It also assumes you can read a contradiction report and know what it means for one schema to subsume another.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| aontu language server and MCP server | The agent’s client speaks LSP or MCP, and errors should arrive while it edits | Two more processes per session, and an LSP range that can point into the wrong file | The client passes neither diagnostics nor tool results to the model |
| Claude Code PostToolUse hook | The agent is Claude Code, and every edit should be checked as it lands | Ties the check to one agent, and it runs after every edit, so it must stay fast | Several agents and people edit the spec, so the check belongs in the repository |
CUE cue vet in CI | The data is YAML the agent already edits, and CI is the gate | The agent hears about an error after a push, in a log it may never read | The agent needs the error in the same turn as the edit |
| pre-commit hook | Any editor, any agent, one check at commit time | Silent until the commit, and git commit --no-verify skips it entirely | The agent should be corrected before it stacks edits on a bad one |
The language server answers first. Across three runs on the shared container that verified this page, a diagnostic arrived 17 to 51 ms after each change, before anything was saved. The hook and pre-commit run the same guard, which took 0.4 to 0.6 seconds as a process: the hook after every edit, pre-commit at each commit. CI is minutes away and outside the agent’s turn. All four check data against the spec in front of them, so a deleted constraint gets past each one unless counterexamples run beside it.
Voxgig maintains aontu. It is one of four options here, and the counterexample check works the
same with cue vet, as the CUE section shows.
Give the spec counterexamples
The spec constrains every entry of service through the &: template.1
# What every service entry must satisfy. An agent may add services, and may not loosen this.
service: {
&: {
owner: string
port: integer & min(1024) & max(65535)
replicas: integer & min(2)
}
}
The data includes the spec, so the language server checks services.aontu against it while the
agent types.
@"./spec.aontu"
service: {
billing: { owner: "payments", port: 8443, replicas: 2 }
search: { owner: "discovery", port: 9200, replicas: 3 }
}
A counterexample is an instance the spec must refuse, and its first line names the path the
refusal must come from. There is one per constraint: low-port.aontu, high-port.aontu,
one-replica.aontu and owner-number.aontu. Each sits one step past its bound, port 1023 rather
than port 80. An agent that wants a service on 443 lowers min(1024) to min(443) rather than
deleting it, and a counterexample on port 80 is still refused after that edit.
# refuse: $.service.legacy.port
# 1023 is the last port that needs root to bind, one below min(1024) in spec.aontu. On the
# boundary, it also fails when the bound is lowered rather than deleted.
service: legacy: { owner: "ops", port: 1023, replicas: 2 }
A constraint with no counterexample can be deleted without the guard noticing, so add one with every constraint you add.
Run the counterexamples beside vet
guard.mjs calls vet from the aontu library twice over: once for the data, once for each
counterexample.
for (const name of cases) {
const file = join(refuse, name)
const want = EXPECT.exec(readFileSync(resolve(dir, file), 'utf8'))?.[1]
if (!want) {
problems.push(`${file}: no "# refuse: <path>" line, so it tests nothing in particular`)
continue
}
const r = check(file)
if (r.verdict === 'valid') {
problems.push(`${file}: admitted. The spec no longer refuses ${want}`)
} else if (!r.findings.some((f) => f.path === want)) {
const got = r.findings.map((f) => f.path).join(', ') || r.verdict
problems.push(`${file}: refused at ${got}, not ${want}, so it no longer tests its constraint`)
}
}
The second branch matters as much as the first. A counterexample refused at a different path no
longer tests its constraint: change port: 80 to port: 8080, replicas: 1 and the file still
fails, for the wrong reason.
Run it where the agent hears it
Merge this into .claude/settings.json. The hook runs the guard after every Edit or Write.
With --hook, a failure exits 2 with the report on stderr, and the hooks reference says Claude
Code shows that stderr to the agent for PostToolUse. The deny rule keeps the built-in edit tools
out of must-refuse/.
{
"permissions": {
"deny": ["Edit(/must-refuse/**)"]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "cd \"${CLAUDE_PROJECT_DIR}\" && node guard.mjs --hook" }
]
}
]
}
}
The same guard runs at commit for every other editor and agent.
minimum_pre_commit_version: '4.4.0'
repos:
- repo: local
hooks:
- id: aontu-guard
name: spec vets, and every counterexample is still refused
entry: node guard.mjs
language: unsupported
files: \.aontu$
pass_filenames: false
language: unsupported is the name pre-commit 4.4.0 gave the old language: system.2 Run
locally under pre-commit 4.7.0, the hook passed on the committed spec and failed on a loosened
one with the same message the guard prints.
Replay an agent against it
loop.mjs copies this directory to a scratch folder and applies three edits from
agent-stub.mjs, a scripted stand-in that takes the place of a model. After each edit it asks
the language server, the MCP server’s vet tool, and the guard.3
node loop.mjs
edit 1: add a metrics service on port 80
lsp constraint at $.service.metrics.port, range 5:21 covers 'discovery", port: 9200, repl'
mcp vet invalid
$.service.metrics.port constraint at spec.aontu:5:21 and services.aontu:6:44
hook FAIL
services.aontu: invalid ($.service.metrics.port constraint)
edit 2: delete min(1024) from the spec, so the error goes away
lsp no diagnostics
mcp vet valid
hook FAIL
must-refuse/low-port.aontu: admitted. The spec no longer refuses $.service.legacy.port
edit 3: put min(1024) back and move metrics to port 9100
lsp no diagnostics
mcp vet valid
hook ok
All three channels catch edit 1. Edit 2 clears the language server and MCP vet, because the
spec they check against is the loosened one, and the guard alone reports it. Edit 3 is the fix
the guard’s message points at.
Check it worked
The suite pins each way a constraint can go missing, and the test below is the one this page exists for.
test('deleting min(1024) passes vet and fails the guard', () => {
const dir = copy({ 'spec.aontu': ['integer & min(1024) & max(65535)', 'integer & max(65535)'] })
try {
const spec = join(dir, 'spec.aontu')
const data = join(dir, 'services.aontu')
const plain = vet(readFileSync(spec, 'utf8'), readFileSync(data, 'utf8'), { schemaPath: spec, dataPath: data })
assert.equal(plain.verdict, 'valid', 'validation alone sees nothing wrong')
const r = guard({ dir })
assert.equal(r.ok, false)
assert.deepEqual(r.problems, ['must-refuse/low-port.aontu: admitted. The spec no longer refuses $.service.legacy.port'])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
node --test guard.test.mjs
1..15
# tests 15
# suites 0
# pass 15
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1891.096559
Two of the fifteen run the guard as a hook process and assert exit code 2 with the report on
stderr, including when spec.aontu is missing. A crash that exits 1 would reach the user and not
the agent.
When it goes wrong
The language server underlines the wrong line. In edit 1 its range is 5:21, which is where
min(1024) sits in spec.aontu, but aontu 0.76.0 publishes it against services.aontu, where
that range covers part of the search entry. The path in the message is right. Tell the agent to
act on the path, or use the MCP vet tool, which names both files and lines correctly.
aontu breaking cannot tell a deletion from a tightening here:
for f in agent-edits/spec-*.aontu; do echo "$f"; npx aontu breaking --mode forward --against spec.aontu "$f" | grep -E '^verdict|sub_'; done
agent-edits/spec-loosened.aontu
verdict: undecided
$.service.&.port: sub_unresolved [compat]
agent-edits/spec-tightened.aontu
verdict: undecided
$.service.&.port: sub_unresolved [compat]
Both answers are undecided, because aontu 0.76.0 does not compare these ranges inside an &:
template. Written as a plain port: field, the same two edits came back breaking and
compatible in a local run. A gate that requires breaking to pass blocks both edits, and one that accepts
undecided admits both. The counterexamples refuse the first and pass the second, which the
suite asserts.
The agent edits the spec with a shell command. The hooks reference
says a PostToolUse hook matching Edit|Write does not run when a Bash command rewrites the file,
so a sed that loosens the spec is first checked at commit. Add Bash to the matcher if your
agent edits through the shell, at the price of half a second after every command.
The agent deletes the counterexample along with the constraint. The test named deleting a constraint together with its counterexample passes the guard shows that gap. The deny rule
covers Claude Code’s own file tools, and the
permissions documentation says it does not reach a
script that writes files itself. Put spec.aontu and must-refuse/ under a
CODEOWNERS
rule as well, so a person reviews any change to either.
Run the same loop with CUE
The same services, written as YAML against a CUE schema with port: int & >=1024 & <=65535, ran
locally under cue v0.17.1. cue vet refused port 80 and named both schema.cue:3:18 and the
YAML line. With >=1024 deleted it passed the services file, and it also passed a
low-port.yaml counterexample that the strict schema refuses. The counterexample check carries
over unchanged:
cue vet schema.cue services.yaml
for f in must-refuse/*.yaml; do ! cue vet schema.cue "$f" || exit 1; done
That loop checks the exit code only, so it misses a counterexample refused for the wrong reason. Match the path in the error text if that matters to you.
CUE has a language server too: the CUE wiki
says cue has carried one since v0.15.0-alpha.1. It was not run for this page, so the latency
comparison earlier on this page covers aontu’s only.
When not to do this
Do not rely on the aontu language server, the MCP server or vet to stop a loosened spec. Each
checks data against the spec it is given, and a weaker spec satisfies all three.
Do not let the agent own must-refuse/. The counterexamples are the record of what the spec
promised, so whoever can delete them can delete the promise.
Do not add the language server for an agent whose client does not pass diagnostics to the model. Check that first: the hook delivers the same finding after every edit with no extra process.
Do not write a counterexample for every bad value you can think of. One per constraint, placed just past its bound, proves the bound has not moved, and more tests the validator rather than the spec.
Related how-tos
Last verified
Verified 2026-10-15 against Node 22.22.0 and aontu 0.76.0. Every output block is what the command
preceding it printed. No model was called: the agent is the scripted stub in agent-stub.mjs.
The pre-commit run, the CUE results and the latency figures were measured on the container,
under pre-commit 4.7.0 and cue v0.17.1, and the page’s checks do not re-run them. The Claude Code
hook was exercised by running its command, not inside a Claude Code session.
Footnotes
-
The 1024 in
min(1024)is two rules that agree. RFC 6335 calls ports 0 to 1023 the System Ports, assigned by IANA, and says nothing about who may bind them. The Linux kernel’sip_unprivileged_port_startdefaults to 1024 and requires root orCAP_NET_BIND_SERVICEbelow it. The same entry explains how to set it to 0, which disables privileged ports altogether. Themax(49151)in the tightened spec is where RFC 6335 ends the User Ports. ↩︎ Back to text -
The pre-commit documentation marks
unsupportedas new in 4.4.0, previouslylanguage: system, and says the alias will be removed in a future version. It is the setting for an executable pre-commit does not install or isolate, so the hook runs whatevernodeis on the PATH. The rename trades a description of where the program comes from for a description of how much support pre-commit gives it. ↩︎ Back to text -
The two servers frame the same JSON-RPC differently. The Language Server Protocol puts HTTP-style headers before each message, a required
Content-Lengthand an optionalContent-Typewhose default isapplication/vscode-jsonrpc. The MCP stdio transport ends each message with a newline and forbids newlines inside one.loop.mjscarries a reader for each. ↩︎ Back to text