03. A REST API contract as agent ground truth
REST API contract as the truth an agent codes against; emit→validate→repair
Exercises vet (json/sarif/exit classes), --at, --closed, repair from vet's findings
Rendered from
use-cases/03-api-contract/README.md
in the engine repository. The models, the expected output and the
check.sh that drives the CLI over all of it are in
use-cases/03-api-contract/.
The scenario
A REST API contract for a project-management SaaS (“Nimbus Tasks”):
two entities (User, Project), four endpoints (list/create users,
get/create projects) with methods, paths, query shapes, request and
response bodies keyed by status code, and the single error envelope
every non-2xx response uses.
This is the document an AI coding agent codes against, and the
document it is corrected by: the agent emits a candidate request
body, aontu vet says what does not hold and where, the agent
repairs, and re-vets. repair.py is the mechanical half of that
loop, consuming vet --format json, and the checks drive it end to
end over agent-emitted candidates.
An API contract is the ground truth with the most dependants in an organisation: every client, server, SDK, and test suite derives from it. Holding agents to it cheaply turns contract drift, the classic integration failure, into a CI error instead of a production incident.
The model tree
contract.aon is the whole contract in one evaluation. types is the
wire vocabulary every other branch draws on, entities the two records,
msg the request and response bodies, errors the one envelope, and
api the four endpoints. The four api children are the endpoints an
agent codes against; each has eight keys of its own.
$
├── api
│ ├── create_project (8)
│ ├── create_user (8)
│ ├── get_project (8)
│ └── list_users (8)
├── entities
│ ├── Project (8)
│ └── User (8)
├── errors
│ └── Envelope (1)
├── meta
│ ├── name "nimbus-tasks-api"
│ ├── stability "ga"
│ └── version "1.3.0"
├── msg
│ ├── CreateProjectRequest (3)
│ ├── CreateUserRequest (4)
│ ├── ListUsersQuery (4)
│ └── UserPage (4)
└── types
├── DisplayName string&length(integer&min(1)&...
├── Email re("^[A-Za-z0-9._%+-]+@[A-Za-...
├── Page integer&min(1)
├── PageSize integer&min(1)&max(100)
├── ProjectId re("^prj_[0-9A-Za-z]{20}$")
├── RequestId re("^req_[0-9A-Za-z]{20}$")
├── Role "admin"|"member"|"viewer"
├── Slug re("^[a-z0-9][a-z0-9-]{1,38}[...
├── Timestamp re("^\\d{4}-\\d{2}-\\d{2}T\\d...
├── UserId re("^usr_[0-9A-Za-z]{20}$")
└── Visibility "private"|"team"|"public"
aontu view doc --depth 2 contract.aon draws it, and check.sh pins it
with --out --check. A key with (n) after it is a container the
depth bound stopped at, and n is how many keys are not drawn; a
leaf carries its canon, which is the kind of thing it is rather
than its value.
The model
| file | role | constructs |
|---|---|---|
types.aon | shared wire vocabulary ($.types.*) | hide(), re(), min/max, length(), enum disjunctions |
entities.aon | User, Project | close(), optional k?:, refs |
errors.aon | the error envelope | nested close(), inline list-spread template |
messages.aon | request, query and page shapes: the vet anchors | close(), refs, [ &: $.entities.User ] |
api.aon | endpoint registry | &: spread as self-policing shape, type() marks, numeric status-code keys |
contract.aon | entry point | @"file" includes |
user-page.aon | the page body as a root-anchored, single-message schema | vetted without --at |
evolution/tighten-page-size.aon | a proposed v1.4 change: page_size capped at 50 | constraint meet (max(100) & max(50)) |
bad/new-endpoint-method.aon | a method: FETCH endpoint | the registry spread refusing it |
repair.py | the mechanical half of the agent loop | consumes vet --format json |
data/*.json | agent-emitted candidates: request bodies, a query, and entity, page and envelope response bodies; one good, several wrong |
The vocabulary in types.aon is hide()-marked: it never appears in
generated output, and every other file references it. re() implies
string, and where a pattern exists its quantifiers double as length
bounds (Slug is 3 to 40 characters by its regex alone);
DisplayName has no pattern, so it is sized with
length(min(1) & max(80)). Timestamp spells optional fractional
seconds as an unquantified alternation, (Z|\.\d{3}Z), because re()
refuses a quantifier on a group that itself contains a quantifier (the
rule and its reason are in the language
reference).
Every wire message is close()d, so a surplus or misspelled key is a
conflict and never a silently ignored extra (the
additionalProperties: false of OpenAPI, in one call). The messages,
entities and error envelope stay unmarked, and they hold enums no
candidate has resolved, so the contract is a schema: aontu contract.aon refuses to generate, and the ways to read it are
--canon, hash, get '$.api', and vet --at.
The registry in api.aon constrains itself. Its &: spread applies
one closed endpoint shape (method, /v1/ path, summary length, auth)
to every entry, so a malformed endpoint refuses to evaluate with no
tooling beyond the contract. The schema-bearing fields are
type()-marked: they unify, they serve as vet --at anchors, and
they are omitted from generation, so get '$.api' contract.aon prints
a concrete inventory in which each responses map is {}, and
get --keys lists the status codes.
UserPage is the list body, written once as
items: [ &: $.entities.User ]: the spread template validates an
array of any length, element by element. user-page.aon restates the
same four fields at the document root, so the page can also be vetted
without --at.
repair.py reads the JSON report and applies the repair each finding
implies. A constraint finding’s expected residual
(integer&min(1)&max(100)) is enough to clamp a number. An empty
finding names the admissible alternatives in its schema site
("name"|"-name"|"created_at"|"-created_at"), and the script
nearest-matches among them. A closed finding names the refused key,
and the script nearest-matches it against the declared keys from
aontu model get --keys. A closed finding’s path is relative to the
candidate document ($.emial) where a constraint finding’s carries
the anchor ($.msg.CreateUserRequest.email); the script accepts
both spellings.
What check.sh proves
-
aontu contract.aondoes not generate: exit 1 with[aontu/disjunct_no_gen]at the first enum no candidate has resolved (the error envelope’scode). -
--canon contract.aonmatchesexpected/contract.canonbyte for byte: the ground-truth serialization is stable and keeps every constraint. -
get '$.api'andget '$.api.create_user'match their goldens: a concrete endpoint inventory with thetype()-marked schemas omitted. -
That inventory prints
"responses": {};get '$.api.create_user.responses' --keyslists the status codes201,400,409. -
hash contract.aonprints anaon1-pin, andagentsmd contract.aonemits aGround truth:stanza naming the file and the pin. -
why '$.msg.CreateUserRequest.email'traces the requirement tomessages.aon(the$.types.Emailreference atmessages.aon:8:12, then the pattern attypes.aon:14:10). -
A well-formed
CreateUserRequestcandidate isverdict: valid, exit 0. -
Wrong types (
"name": 42,"send_invite": "true") are refused, exit 1, with[aontu/constraint]and[aontu/no_scalar_unify]; the constraint finding carriesexpected: string&length(integer&min(1)&max(80)). -
A malformed email and
"role": "owner"are refused: the constraint finding shows theEmailpattern asexpected, and the[aontu/empty]finding lists the alternatives with the enum’s own location:$.msg.CreateUserRequest.role: empty [conflict] [aontu/empty]: Cannot unify values at path $.msg.CreateUserRequest.role data: data/create-user-subtle.json:4:11 ("owner") schema: types.aon:34:9 ("admin"|"member"|"viewer") -
A missing
nameisverdict: incomplete, exit 3, with[aontu/mapval_required]; the schema site namestypes.aonat the line that declaresDisplayName, and the check reads that line back from the file the site names:$.msg.CreateUserRequest.name: mapval_required [incomplete] [aontu/mapval_required]: Cannot resolve value at path $.msg.CreateUserRequest.name schema: types.aon:28:25 (string&length(integer&min(1)&max(80))) -
A missing
role, a required enum, isincompletetoo, exit 3:$.msg.CreateUserRequest.role: disjunct_no_gen [incomplete]. The loop’s “add what is missing” branch covers both. -
A misspelled key (
emial) and a surplus key (favourite_colour) are refused with[aontu/closed]; eachclosedfinding names the key with its data position, relative to the document, and carries no suggestion:$.emial: closed [conflict] [aontu/closed]: Cannot resolve value at path $.emial data: data/create-user-surplus.json:2:12 ("alan.turing@example.com") -
A misspelled anchor (
--at '$.msg.CreateUserRequst') isverdict: error, exit 4, withnote: did you mean CreateUserRequest?. -
Repair round A, from
--format jsonalone: theconstraintfinding carriesexpected: integer&min(1)&max(100)andactual: 500, sorepair.pyclampspage_sizeto 100; theemptyfinding has noexpectedfield, but its schema site holds"name"|"-name"|"created_at"|"-created_at", so the script corrects"namez"to"name". The result matchesexpected/query-repaired.jsonand re-vetsvalid. -
Repair round B: with the declared keys from
get '$.msg.CreateUserRequest' --keys, the script renamesemialtoemailand dropsfavourite_colour; the result matchesexpected/surplus-repaired.jsonand re-vetsvalid. -
Two candidates in one run: the worst verdict wins (
invalid, exit 1). -
--format sarifis SARIF 2.1.0: two results, each located in the candidate file, with the native finding (code,expected, sites) underproperties; the exit code is still 1, so CI upload and loop control coexist. -
--at '$.api.create_user.responses.201'reaches theUserentity through the registry’stype()mark and numeric key; a well-formed 201 body isvalid. -
An error envelope with a
detailslist isvalidat$.errors.Envelope. -
--at '$.msg.UserPage'vets a two-item page against the[ &: $.entities.User ]spread:valid. -
user-page.aon, vetted without--at, answers all three verdict classes: the good page isvalid; a page whose second user has the email"grace.hopper@"isinvalidwith a[aontu/constraint]finding that quotes the value and its position in the candidate; a page with nototalisincomplete. -
bad/new-endpoint-method.aon(method: FETCH), evaluated with--canon --include-root ., is refused by the registry spread:[aontu/empty],"FETCH"against"GET"|"POST"|"PATCH"|"DELETE". -
breaking --against contract.aon evolution/tighten-page-size.aonisverdict: breaking, exit 1, withcompat_narrowedonPageSize(expected: integer&min(1)&max(50),actual: integer&min(1)&max(100)). The contract compared against itself isverdict: compatible, exit 0: two identical templates are the same template, by their hash form, and that holds for a path-dependent one such as[ &: $.entities.User ].
Running it
From this directory, ./check.sh runs all 23 assertions and exits 0;
set AONTU= to point at another CLI build. The repair loop by hand:
aontu vet --at '$.msg.ListUsersQuery' --format json contract.aon data/list-users-query-bad.json > findings.json
python3 repair.py --candidate data/list-users-query-bad.json --findings findings.json --out repaired.json --anchor '$.msg.ListUsersQuery'
aontu vet --at '$.msg.ListUsersQuery' contract.aon repaired.json # verdict: valid
The CI shape of the first command is in Validate data in CI; the evolution gate is in Gate schema changes.