Skip to content

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

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.

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.

  • 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-core gets the model and nothing else: no parser, no HTTP client, no JSON library, no solver, no database driver.
  • Exhaustive switch gives 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.
  • UnsupportedSchema is 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 SpecificationIssue when 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 and restest recheck silently, which is worse than a loud failure the parser is required to prevent.
  • 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 Schema record with every field nullable. Shorter to write and it moves the whole burden onto readers: "if items is 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, hashCode and immutability by hand in forty places, which is exactly where value-type bugs live.
  • Inlining every $ref instead of a SchemaReference variant. 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 exhaustive switch in every downstream module, which is the cost this ADR claims to be avoiding.
  • Jackson's JsonNode instead of our own JsonValue. Tempting, and it would put a JSON library in the published surface of every consumer of restest-core, to be kept in step with whatever version they already use. JsonNode is 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.json has 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-core publishes, 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, DOCUMENT carries 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 DEGRADED issue saying nothing was lost. It contradicts what DEGRADED means, changes the constructor of a record restest-core publishes, 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_2 becomes 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_1 and create_2 beside six operations called get; 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.

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.

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. Owner is still Owner in ApiModel.schemas(); what is lost is that it was built from OwnerFields. 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. refName deliberately 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 Error is 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.

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.

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.

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_date read Tue Apr 25 02:00:00 CEST 2017 and now reads 2017-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's Service, Shipment and VehicleType, 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 and GeocodingResponse in 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 (minProperties above maxProperties) 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.