ADR-0012: The canonical model is immutable records and sealed types, and says so when it cannot represent something
Status: Accepted, amended at M1.8, M2.1a and M2.1b, and on 17, 26 and 29 October 2026 Date: 2026-09-12
Context
Section titled “Context”Every part of the tool after the parser works on a representation of the API: the scheduler picks an operation, the generator fills in its parameters, the engine sends the request, an oracle judges the response against the declared shape, the report names what failed. That representation is the widest interface in the project, so how it is built decides how much else is forced.
RESTest 1.x has no such representation. It passes io.swagger.v3.oas.models.* objects around
directly — mutable POJOs from a third-party library, with nullable fields, no OpenAPI 2.0/3.0/3.1
reconciliation, and no way to say "this construct was not understood". Three consequences followed
and are all visible in the audit: the parser version became load-bearing for code that has nothing to
do with parsing; every consumer re-implemented its own null checks; and a construct the tool could
not read was indistinguishable from one the document did not contain.
ADR-0007 already fixes the boundary — the parser sits behind our own interface. This ADR fixes what comes through that boundary.
Decision
Section titled “Decision”Records and sealed interfaces. ApiModel, Operation, Parameter, RequestBodyModel,
ResponseModel, Server, SpecificationIssue and the ten schema variants are records.
CanonicalSchema and JsonValue are sealed, so a generator or oracle that handles all but one case
fails to compile rather than falling through a default and producing nothing.
No nulls, and no shared mutability. Mandatory components are null-checked in the compact
constructor; absent scalars are Optional; collections are copied on the way in and handed back
unmodifiable. Maps and sets that carry document order — an object's properties, a body's media types
— are copied through LinkedHashMap/LinkedHashSet rather than Map.copyOf, whose iteration order
is randomised per JVM and would make two runs of the same specification generate different requests.
restest-core carries its own JSON value model. JsonValue is sealed over null, boolean,
number, string, array and object. Numbers are BigDecimal, normalised so 1 and 1.0 are the same
value.
The model refuses what contradicts itself or could never be sent, and the parser catches it.
minLength above maxLength, a negative count, a non-positive multipleOf, an exclusive bound that
leaves no room, two shapes for one media type, two names for one header, one parameter declared
twice in one location, a path template with no parameter to fill it, a required body with no media
type, two operations under one identifier: all IllegalArgumentException. The rule behind the list
is that the model never holds a value whose own lookups would have to answer "whichever was declared
first", and never one that describes a request nobody could assemble. From M1.2 the parser turns that
exception into a SpecificationIssue, skips the operation and reads the next one.
The promise stops at what a constructor can compare, and deliberately so. type: integer, minimum: 1.2, maximum: 1.8 is accepted here and satisfied by no value; deciding that needs the
solver that arrives at M5.2, not a compact constructor. SchemaChecks says as much, so that nothing
downstream mistakes the check for a satisfiability guarantee.
What cannot be represented is recorded, not lost. UnsupportedSchema carries a printable reason;
AnySchema is the different, legitimate case of a schema that declares no type, and NothingSchema
the third case of a document that was understood and said no value is acceptable —
additionalProperties: false, which is one of the most common things a strict API states about
itself. ApiModel.issues() carries everything the document said that we could not use, and travels
into every report.
A schema may be referred to by name rather than copied. SchemaReference names a shape that
ApiModel.schemas() holds. This is what makes a recursive shape representable at all — held by
value, a comment with replies or a category with sub-categories would have to contain itself — and it
is what keeps the name, which inlining destroys and which the operation dependency graph at M4.1, the
value dictionary at M2.7 and restest explain all want back. Resolution is explicit and one step
deep: a document may point one name at another, and a model that followed chains silently would loop
for ever on one that points at itself.
Media types are resolved the way HTTP resolves them, not compared as strings. Exact type first,
then type/*, then */*. The wildcard is not exotic: it is what a Swagger 2.0 document's default
produces becomes, and a literal comparison would have an oracle quietly validate nothing for a
large share of real specifications.
Facts the document supplies are kept, even when using them comes later. Server variables carry
the defaults OpenAPI requires, so Server.resolvedUrl() can produce a base URL without asking the
user — design principle 1. An operation carries its own servers override, so requests for an API
that serves uploads from another host do not go to the wrong origin. A response header carries
required, without which "the API did not send the header it promised" cannot be told from "the API
never promised it". A parameter carries the media type of a content-declared value, so a
JSON-in-query parameter does not cost us the whole operation. A SpecificationIssue carries the
operation it concerns and what it cost — skipped, degraded, or a problem with the document itself —
because "this operation is not being tested" and "it is being tested with less than the document
says" are different facts, M1.2 has to report both, and a reader who cannot tell them apart cannot
judge the run.
Exclusive numeric bounds are numbers, not flags. That is the JSON Schema 2020-12 and OpenAPI 3.1
shape, and it is lossless in both directions: a 3.0 document's minimum: 5 plus
exclusiveMinimum: true converts into it, while the reverse modelling gives a 3.1 document writing
exclusiveMinimum: 5 with no minimum nowhere to put the number.
Every operation has an identifier. The declared operationId when there is one; otherwise
GET /pets/{petId}, synthesised from method and path.
Consequences
Section titled “Consequences”- Two runs, two APIs or two versions of one API coexist in one JVM with no interference, which is design principle 6 at the level of the data and what the library use case needs.
- A consumer depending on
restest-coregets the model and nothing else: no parser, no HTTP client, no JSON library, no solver, no database driver. - Exhaustive
switchgives us a compile error, in every downstream module, the day a schema variant is added. That is the mechanism by which M2.1's composition support will be prevented from being half-adopted. - A run against a partly unreadable document is honest: the report can say which operations were skipped and which shapes were guessed at.
- It costs conversion work in
restest-spec, once, in the module whose job that is. - No convenience accessor collapses the four numeric bounds into a lower and an upper one. A bound without its strictness is the wrong answer to the question M2.3's boundary walk asks, so the caller reads the components that say which it is.
- Records with many components are verbose to construct. Mitigated with a few factories and
with…methods; if M1.2 shows the parser wants builders, they arrive there rather than being guessed at now. UnsupportedSchemais a standing invitation to leave gaps unfilled. The mitigation is that its reason is printed in reports, so an unread construct is visible to users rather than only to us.- Rejecting duplicate operation identifiers is the one refusal scoped to the whole document rather
than to one operation, which puts a real obligation on M1.2: the parser must make identifiers
unique — recording a
SpecificationIssuewhen it does — before it constructs the model. The alternative, a model that answers its own lookups with whichever of two operations came first, would corrupt per-operation settings, stored interactions andrestest rechecksilently, which is worse than a loud failure the parser is required to prevent.
Alternatives considered
Section titled “Alternatives considered”- Pass the parser's own types around. What 1.x does. Free today, and it makes the parser version part of everyone's contract, leaves the 2.0/3.0/3.1 differences in every consumer, and has nowhere to put "not understood". Rejected by ADR-0007 already.
- One
Schemarecord with every field nullable. Shorter to write and it moves the whole burden onto readers: "ifitemsis set it is probably an array" is a convention, not a type. No compiler help, and no exhaustiveness when a variant is added. - Classes with builders instead of records. More construction comfort, at the price of writing
equals,hashCodeand immutability by hand in forty places, which is exactly where value-type bugs live. - Inlining every
$refinstead of aSchemaReferencevariant. Simpler to consume — every schema is complete where you find it — and it cannot express recursion at all, throws away the name, and multiplies the size of a model whose schemas are shared by dozens of operations. Adding the variant later would break every exhaustiveswitchin every downstream module, which is the cost this ADR claims to be avoiding. - Jackson's
JsonNodeinstead of our ownJsonValue. Tempting, and it would put a JSON library in the published surface of every consumer ofrestest-core, to be kept in step with whatever version they already use.JsonNodeis also mutable, which would put a hole in the middle of an otherwise immutable model. - Throw nothing; accept contradictory constraints. Keeps the model permissive and moves the contradiction downstream, where each generator and oracle rediscovers it with less context to report it from. Design principle 2 asks for the failure to be reported, which needs it to be detected somewhere first.
Amendment (29 October 2026): only a reply that could not be read makes an operation read in part
Section titled “Amendment (29 October 2026): only a reply that could not be read makes an operation read in part”Date: 2026-10-29
ApiModel.readInPart counts a DEGRADED issue only when it is about one of the operation's
replies, whole, and the reader says which ones are by where it puts them: a reply it cannot read is
located at the reply itself, paths./pets.get.responses.404, rather than at the operation. The
checks of a status code and of a content type the document does not declare leave alone an
operation read in part, since a reply the document declares and RESTest could not read would
otherwise look undeclared. Until now every DEGRADED issue counted, and most hide no reply: a parameter, a request
body or the body of a reply built from a shape RESTest does not fully understand — a
discriminator, a shape the document never defines — is read with less than the document holds,
but every status code and content type the operation declares is still read. Such an operation lost
both checks with nothing missing from its replies. Of the 3,627 operations in the 98 documents read
for the amendment of 26 October, 378, in 12 documents, counted as read in part; 4 do now, all in
catwatch, which declares a reply under the status code 0. On ohsome-api 126 of 134 operations were
left alone, and on jasper 59 of 61.
Where the issue is, rather than what it cost, because the two kinds cost the same: the operation
is tested with less than the document holds, which is what DEGRADED says. Three other answers
were weighed:
- A sixth effect, for a reply that could not be read. It would say two things in one field, the
cost and the place, and it would change the word
report.jsonhas written since 2.2.0 for the very issues the two checks were built around. - A field on the issue naming the part of the operation it is about. The location already says
where an issue is, which is what it is for, and the field would change the constructor of a record
restest-corepublishes, as the amendment of 17 October declined to. - Counting whatever is inside a reply too: a shape not understood in its body, a header given as
a reference that leads nowhere. Neither hides a status code or a content type, which is all the
two checks look for; the check of a body against its shape has a reason of its own for a shape it
cannot use,
shapeUnusable.
A location counts only when it is a reply itself: what follows its last .responses. is a
status code, which holds no dot. An issue placed later inside a reply — a header's, at
…responses.200.headers.X-Rate — then does not count, and SpecificationIssue says where a reply
that could not be read goes, for whoever else writes issues.
What changes in a run: those operations are held to the status codes and content types they
declare, and a reply they do not declare is reported as on any other operation; with
checks.failOnWarnings on, such a run can now end with 1. The screen, documentIssues in
report.json and the page give a reply that could not be read its own location, and the words for
readInPart on the screen, in notChecked[].why and on the page say that a reply the document
declares could not be read.
Amendment (26 October 2026): what no request can carry is left out, and said
Section titled “Amendment (26 October 2026): what no request can carry is left out, and said”Date: 2026-10-26
A path parameter whose path has no place for it is left out of its operation, and a
SpecificationIssue with a fifth effect, LEFT_OUT, says so. The model refuses a place in a path
with nothing to fill it, and accepts the converse: a path parameter whose name the path does not
write as {name}. The request can still be assembled, so the operation is not lost. But that
parameter is never sent, since a request's path is filled by writing each path parameter's value
where its {name} stands. Kept, it was given values and changed on purpose like any other; the
request then went out exactly as the API had already accepted it, and the API accepting it again was
reported as F206, invalid data accepted. 2.2.0's release evaluation found this on scout-api, the
only one of 98 documents read — the Web Fuzzing Dataset's 52 and the 46 of RESTest's corpus — that
declares such a parameter, on three operations: 4,787 of the 9,181 F206 of one run of it.
Left out by whoever reads the document, once the operation is built. What makes an operation
impossible to build — one parameter declared twice, a place in the path with nothing to fill it —
costs it what it did before, and nothing is said of a parameter of an operation that cannot be read.
The model itself is unchanged and still accepts such a parameter: refusing it there would cost the
whole operation, which the reader would then skip. Leaving the parameter in and having one strategy
not change it, as a strategy of kind mutation already does not change a property a reply alone
carries, was weighed and set aside: the other strategies would still give it values, a value given to
it would be remembered as one the API had accepted, and the document's mistake would go unsaid.
A fifth value, for the reason the rename got the fourth: the effect is what says what an issue
cost, and this one costs nothing a request ever carried. DEGRADED would say the operation was read
in part, which keeps the checks of a status code and of a content type the document does not declare
away from it; DOCUMENT carries no operation. ApiModel.readInPart does not count LEFT_OUT.
What changes in a run of such a document: no strategy gives the parameter a value, so none is
changed on purpose and none is remembered as accepted; a request with nothing else to change is not
changed at all; and the screen, documentIssues in report.json and the page say what was left out
and why, one line per operation.
Amendment (17 October 2026): an operation renamed is not one read in part
Section titled “Amendment (17 October 2026): an operation renamed is not one read in part”Date: 2026-10-17
A SpecificationIssue has a fourth effect, RENAMED, beside OPERATION_SKIPPED, DEGRADED and
DOCUMENT. An operation is renamed when the document gives its operationId to another one
RESTest reads first: the model holds no two operations under one identifier, so the later one is
called by its method and path, PUT /v1/users/{id}. Nothing else about it is lost — its parameters,
body and replies are read whole — so it is tested with everything the document says, under a name of
RESTest's own. It used to be recorded as DEGRADED, "tested with less than the document holds",
which it is not; and since ADR-0016's amendment of the same day,
the checks of a status code and of a content type the document does not declare leave alone every
operation a DEGRADED issue names. On scout-api, the only one of the Web Fuzzing Dataset's 52
documents that repeats identifiers, 22 times, that kept 8 operations whose replies the document does
not declare away from both checks.
A fourth value, because the effect is what says what an issue cost, and a rename costs what none of the three say: the name alone. Three other answers were weighed:
DOCUMENT, a problem with the document as a whole. One line to change, and the screen, which prints where and what, would look the same. But the issue is about one operation,DOCUMENTcarries none, and this record keeps the operation so that what could not be used can be shown beside the operation it concerns. swagger-parser already reports each repeat as a document-wide line of its own.- A flag on a
DEGRADEDissue saying nothing was lost. It contradicts whatDEGRADEDmeans, changes the constructor of a recordrestest-corepublishes, and has two fields say what one issue cost. - No issue, and the declared identifier kept on
Operation. A field on a central record for one case, and a rename nobody is told about.
An operation is read under the name it is tested under. The name used to be settled after every
operation had been read, so whatever RESTest could not read about the later of two operations
sharing an identifier — a reply pointing at a response the document never defines, say — was said
of the earlier one, which kept the name. The earlier one then counted as read in part, and the later
one, had only the rename's effect changed, would have been held to replies RESTest could not read.
The name is now settled first, and the first operation RESTest reads keeps it whether or not it can
be read; every later one is called by its method and path, read or not. RESTest reads the paths in
the document's order and, within a path, the methods as GET, PUT, POST, DELETE, PATCH,
HEAD, OPTIONS, TRACE, whatever order they are written in, as before. So every issue names the
operation it is about, in either order: what could not be read about an operation RESTest skipped is
never said of the one after it, and a run no longer lists as untested a name it tested. Before, an
operation that could not be read took no name, and the one after it kept the name.
The method and path, rather than a number after the name. The rule dates from #289 and was not recorded until now:
- A number depends on the document's order.
update_2becomes another operation when one is added before it or the paths are reordered, and a plan, a list of values written by operation, or a comparison of two runs would follow it there without a word. A method and a path are fixed by the document, which is why an operation the document gives no identifier is called by them too. - Generators already number repeats. scout-api's document declares
get_1,get_2,create_1andcreate_2beside six operations calledget; a number of RESTest's own would meet one the document uses. - They say which operation it is without the document open, and a plan already names an
operation that way (
only: ["GET /pets/{petId}"]). OpenAPI gives no two operations one method and path, so a number is added only when another operation declares that very text,GET /bats, as its identifier.
What a person sees changes in names, and in what the two checks report. The screen still lists
a rename among the "part(s) of the document could not be read", its line now naming the identifier
already taken and the new one. An operation that repeats an identifier and cannot be read is named
by its method and path where the run lists what could not be tested, and in report.json's
skippedOperations. When the first of two such operations is the one that cannot be read, the
second, which used to keep the identifier, is tested, stored and reported under its method and path,
and a plan's only or a list of values naming the identifier now names the first. And F920 and
F921 judge every renamed operation. A DEGRADED or RENAMED issue is still nowhere in
report.json, which lists only the operations that could not be tested.
Amendment (M2.1b)
Section titled “Amendment (M2.1b)”Date: 2026-09-17
There are eleven schema variants, not ten.
ChoiceSchema carries the shapes a value is allowed to have when a document offers a choice between
them. The reasoning, the alternatives weighed and what is deliberately left out are in
ADR-0018; recorded here because this is the record that says how
many variants there are, and a count that is wrong is worse than no count.
It is also the first time the mechanism this ADR claimed for a new variant has been exercised. Six
places stopped compiling until they handled the new shape, which is what was wanted. One did not: a
chain of instanceof tests inside the specification module, which a sealed switch would have
caught and which instead needed a guard written on purpose. Worth knowing before the twelfth variant:
the guarantee is a property of switch, not of the sealed interface.
Amendment (M2.1a)
Section titled “Amendment (M2.1a)”Date: 2026-09-17
A $ref that is one half of a combination is written out in place; every other $ref stays a
name.
"Inlining every $ref instead of a SchemaReference variant" is listed below as an alternative this
ADR rejected, and rightly: it cannot express recursion, it throws away a name that M4.1's dependency
graph and M2.7's value dictionary both want back, and it multiplies the size of a model whose shapes
are shared by dozens of operations.
Combining allOf cannot honour that. The whole content of "this shape is that one and also has an
identifier" is the other shape, so there is nothing to combine until it has been looked up. A model
that kept the name here would be a model that had not combined anything.
So the reversal is narrow and its edges are stated rather than left to be discovered:
- Only a half of a combination is written out. A property, a parameter, a body and a response
that name a shape all still hold a
SchemaReference, unresolved and one step deep. - The name that survives is the combined shape's own.
Owneris stillOwnerinApiModel.schemas(); what is lost is that it was built fromOwnerFields. Nothing downstream asks that question, and matching on property names — which is what M4.1 does — sees more after combining than before, not less. - A half naming a shape in another document is not written out at all.
refNamedeliberately discards the file part, so inlining one would silently substitute whichever local shape happened to share the name. That half is reported as unread instead. - Following is bounded twice. A combination may be nested 64 deep and may look up 2,000 named
halves; beyond either, what is left is reported as unread. Depth, because following a chain of
several thousand exhausts the stack, and an
Erroris not something the parser's last-resort handler can hold: one unusual document would cost the whole run rather than one operation, which is exactly what design principle 2 forbids. Breadth, because halves that share a base are reached along every path that leads to them, so the work doubles at every level and a document of a few kilobytes could spend a whole testing budget before the first request is sent.
What it costs
Section titled “What it costs”A combined shape is bigger than the two halves it came from, and an ApiModel that holds a hundred
of them holds that many copies. Measured over the fifty-document corpus the difference is not visible
against everything else a parse allocates, and the alternative is not reading the documents at all.
The two limits are arbitrary numbers, which this project usually refuses. They are defensible here because neither is a tuning knob: both are far outside anything a document written by a person reaches, and being wrong about them costs a report saying a shape was left unread — never a wrong value, and never a lost run.
Amendment (M1.8)
Section titled “Amendment (M1.8)”Date: 2026-09-15
A schema that names no type but describes an object is read as an object, and a stated value that cannot be written down exactly is not written down at all.
Two things this ADR settled turned out to be settled only halfway, and an audit of the branch found both. Neither is a new capability; both are this decision meaning what it already said.
AnySchema was swallowing objects. The decision names AnySchema as "the legitimate case of a
schema that declares no type", which is right, and the converter applied it to every schema without a
type — including the very common one that leaves type: object out while declaring properties,
required, additionalProperties or a property count. Those schemas came out as "any value at all",
and the properties, which are the whole content of such a schema, were gone. The tool then offered a
number or a word where the document had described an object with named fields.
A schema carrying any of those four keywords and no type is now read as an object. The converter
already did exactly this for the other half of the rule — a schema with items and no type is an
array, filled in by the parser before we see it — so the two halves now agree.
Strictly, JSON Schema says those keywords constrain only values that happen to be objects and let everything else through, so a document could mean "anything, but if it is an object, this shape". No document written by a person means that, and reading it that way costs the shape. The strict reading is recorded here rather than in the code, so that whoever revisits it knows it was considered and not overlooked.
Stated values were being invented. default and enum do not arrive as the text the document
wrote. A date arrives as a java.util.Date, a base-64 string as a byte[], an object as the
parser's own document node. Written out the ordinary way, those became Fri Jan 31 00:00:00 CET 2020, [B@77eca502 and a string containing {"a":1} — none of them a value any API would accept,
and the second one different on every run, which quietly broke the promise that one seed and one
document make the same requests twice.
Each kind is now turned back into what the document said: base-64 text, the day as 2020-01-31, a
moment with its seconds, an object as an object. A kind that cannot be converted exactly produces
nothing rather than an approximation, and the schema simply has no default — which is the state
it would have been in had the document not stated one, and leaves the generator inventing a value as
it would have anyway.
The date deserves a footnote, because the obvious fix is the wrong one. The parser reads 2020-01-31
as midnight where the machine is, so reading it back in the machine's own zone returns the day the
document wrote, in every zone. Reading it back at UTC looks more careful and is wrong: anywhere east
of Greenwich that midnight belongs to the previous day, and the default silently shifts by one. This
was measured in four zones, not reasoned about.
A name nothing declares is not understood. A $ref to a schema the document never defines, or
to one in a second file we deliberately do not open, used to count as perfectly fine as long as the
reference itself was well formed. Those operations were tested as though fully understood and nobody
was told the shape was missing. Such a reference now counts as not understood, which degrades the
operation and says so — the same answer this decision already gives for every other construct it
cannot read.
Consequences
Section titled “Consequences”Measured across the whole fifty-document corpus, before and after:
- Four declared values are now right that were wrong, out of seven differences in the whole
corpus. One is a query parameter's default, in the Amadeus document:
departure_datereadTue Apr 25 02:00:00 CEST 2017and now reads2017-04-25— and read differently again on a machine set up in another country, which is the reproducibility promise broken in the smallest possible way. It is also the only value in the corpus that the tool actually sends, so it is the whole of the user-visible gain today. The other three are array defaults inside the Graphhopper document'sService,ShipmentandVehicleType, each the string[0]where the document wrote an array of one number; those will be sent from M2.5, when bodies are generated. - Two named schemas change shape, and no parameter anywhere changes shape. The two are
scim-user.groups[]in the GitHub document andGeocodingResponsein the Graphhopper one, both response schemas, both previously read as "any value at all" and now read as the objects they describe. The untyped-object rule therefore changes nothing that is sent on these fifty documents today. It is still right, and the reason to make the change now rather than at M2.5 is that request bodies arrive then and will be generated from exactly these schemas; a body generator built on top of "any value at all" would produce scalars where objects were described, and the cause would be two milestones behind it. - One more issue is reported, in the hand-written fixture whose reference has never resolved: a degraded issue naming the operation, where before the only word about it came from the parser's own validation and was filed against the document as a whole. On documents that were always this incomplete, the report becoming longer is it becoming accurate.
- A document stating a default RESTest cannot represent loses that default rather than gaining a wrong one, and an enumeration it cannot read in full is reported as unreadable rather than silently shortened — because a shorter list is a different claim, not a smaller one.
- An untyped schema whose own limits contradict each other (
minPropertiesabovemaxProperties) now skips its operation, where before it was read as "any value" and tested. That is the model refusing what it already refuses everywhere else, reached by one more route.