The OpenAPI source: what it can and cannot see¶
The OpenAPI source needs no database credentials and sees everything your API key can see — column types, nullability, primary keys, single-column foreign keys, enums, and RPC argument names, types and (in almost every case) declaration order. It cannot see unique or check constraints, identity/generated columns, exact integer widths below
bigint(on PostgREST ≥ 14.8 — an older server keeps them, why), or function return types — and for aVOLATILEfunction with defaulted arguments it recovers only part of the argument order, telling you which part. Function volatility depends on your server: below PostgREST 13.0.5 the document does not encode it at all, and castiron reports it as unknown rather than guessing (why). Point castiron at the database itself when you need those.
That is the whole page in one paragraph. The rest explains why each limit exists, what it does to your generated code, and what to do about it — because a code generator that overstates what it knows is worse than one that tells you where it stops.
The trade¶
flowchart LR
PG["Postgres catalogue<br/>every fact about your schema"] --> PR["PostgREST"]
PR --> DOC["OpenAPI document<br/>filtered by your API role"]
DOC --> IR["castiron Schema IR"]
IR --> M["typed models"]
PR -.->|not encoded| LOST["UNIQUE / CHECK / EXCLUDE<br/>identity and nextval defaults<br/>smallint vs integer (PostgREST ≥ 14.8)<br/>numeric precision, domains<br/>function return types"]
castiron reads one document: the OpenAPI (Swagger 2.0) description PostgREST publishes
at its API root. One authenticated GET, no driver, no connection string, no firewall
rule. Every fact castiron knows about your schema is a fact PostgREST chose to encode
there — and PostgREST built that document to describe an HTTP API, not to reproduce a
Postgres catalogue.
So the source's ceiling is PostgREST's ceiling. That is not a defect castiron can patch: the information is not in the file. What castiron can do — and does — is refuse to guess. Anything the document does not state stays unknown rather than becoming a plausible-looking lie in your models.
Two further consequences of reading an API description rather than a catalogue:
- You see what your API role sees — object by object, not column by column. Tables and
functions your key cannot access are not "hidden": they are absent, indistinguishable from
not existing. This is why the run summary prints the counts it read;
read 2 tableswhen you expected 20 is your signal. Column-level privileges are a different matter — PostgREST does not apply them to the document at all, so castiron emits fields for columns your key cannot read. See Privileges filter objects, not columns. - One schema per document. PostgREST serves one schema at a time, selected by
Accept-Profile(castiron's--schema). Cross-schema foreign keys are not resolvable from a single run.
The full picture¶
Legend: ✅ full — the fact is exact. ⚠ partial — something survives, degraded. ❌ absent — not in the document at all. n/a — the comparison does not apply.
Tables, views and columns¶
| Fact | OpenAPI source | Live DB source (planned) | Why |
|---|---|---|---|
| Table/view names, columns | ✅ full | ✅ | definitions |
| Column nullability | ✅ full for tables; ⚠ every view column is reported nullable | ✅ | required lists exactly the NOT NULL columns; PostgREST reports view columns nullable |
| Table vs view | ⚠ inferred from one signal — no view marker is emitted, so a non-empty required list means BASE TABLE and anything else means VIEW; see below |
✅ relkind |
the document carries no marker |
| Column type | ⚠ on PostgREST ≥ 14.8, smallint and integer both arrive as int32 and bigint as int64; below 14.8 format carries the Postgres type name and all three widths stay distinct. Every other type keeps its Postgres name on either server. See below |
✅ exact | PostgREST's toSwaggerFormat, which only spells integers int32/int64 from 14.8 |
Numeric precision/scale, varchar(n) typmod |
❌ lost (maxLength survives, but see below) |
✅ | format_type(atttypid, NULL) erases them |
| Domain types | ❌ collapse to their base type | ✅ | resolved before the document is built |
| Column default | ⚠ only when it survives a JSON decode — string-ish defaults (now(), gen_random_uuid(), 'text') come through; nextval(...) is silently dropped |
✅ | PostgREST re-quotes string-ish defaults as JSON; for other types the raw default text must itself be valid JSON |
| Identity / generated columns | ❌ always false — see below | ✅ | no nextval reaches the document |
| Column comments | ✅ full | ✅ | description |
| Table comments | ✅ full | ✅ | definitions.<t>.description → the model docstring |
| Objects (tables, views, functions) the API role cannot see | ❌ invisible — RLS and privileges silently shrink the schema | ✅ | PostgREST's default openapi-mode |
Columns the API role cannot SELECT |
⚠ still described, and still emitted — column privileges are not applied to the document; see below | n/a | PostgREST filters relations, not columns — a generated model is not a privilege boundary |
Keys and constraints¶
| Fact | OpenAPI source | Live DB source (planned) | Why |
|---|---|---|---|
| Primary-key membership | ✅ full | ✅ | <pk/> marker in a column's description |
| Composite primary-key order | ❌ not recoverable | ✅ | markers are per column, unordered |
| A view's primary key | ⚠ recorded as UNIQUE, not PRIMARY KEY — see below | ✅ | a downgrade, not a guess |
| Foreign keys | ⚠ single-column only; no schema | ✅ | <fk table='..' column='..'/> marker |
| Composite foreign keys | ❌ invisible; a column in two foreign keys reports only one | ✅ | one marker per column |
| A foreign key whose target table you cannot see | ⚠ the relationship is dropped, the constraint is kept, and castiron warns once — see below | ✅ | privileges filter relations, not markers |
| Constraint names (primary key, unique and foreign key) | ❌ never in the document — castiron synthesizes Postgres's own defaults (<table>_pkey, <table>_<cols>_key, <table>_<column>_fkey) and marks every one name_is_synthesized — see below |
✅ pg_constraint.conname |
not encoded |
| UNIQUE constraints | ❌ absent entirely | ✅ | not in the document |
| CHECK constraints | ❌ absent entirely | ✅ | not in the document |
| EXCLUDE constraints | ❌ absent | ✅ | not in the document |
| Cross-schema / multi-schema | ❌ one schema per document | ✅ | PostgREST design |
Enums¶
| Fact | OpenAPI source | Live DB source (planned) | Why |
|---|---|---|---|
| Enum values on a scalar column | ✅ full, with the type name (schema-qualified when needed) | ✅ | enum + format |
| Enum values on an array column | ⚠ absent — such a column links to an enum only if the same enum also appears on a scalar column somewhere in the document; otherwise it degrades to list[Any] |
✅ | PostgREST looks up pg_enum by base type, which misses array types |
Functions (RPCs)¶
| Fact | OpenAPI source | Live DB source (planned) | Why |
|---|---|---|---|
| Name, schema | ✅ full | ✅ | the /rpc/<name> path key |
| Argument names, has-default | ✅ full | ✅ | the POST body schema's properties and required |
| Argument order | ✅ full for a STABLE/IMMUTABLE function, and for a VOLATILE one whose arguments are all required; ⚠ partial for a VOLATILE function with defaulted arguments — the required prefix is right and the defaulted tail is name-sorted. FunctionInfo.parameter_order says which you got. Below PostgREST 13.0.5 a GET exists for every function, so order is recovered in full for VOLATILE ones too. See below |
✅ | the POST body's properties is a sorted map, but the GET parameters and the body's required are ordered arrays |
| Argument types | ⚠ as degraded as a column type, plus one loss beyond it — the same version-scoped integer collapse (smallint and integer both int32 on PostgREST ≥ 14.8, distinct below it), and char(2) arrives as character with no maxLength, which the same column would carry |
✅ exact | toSwaggerFormat again; maxLength is emitted for columns only |
| An argument's enum values | ❌ never carried on the argument — it links to an enum only if the same enum also appears on a scalar column somewhere in the document; otherwise the argument keeps a bare type name and no values | ✅ | the parameter declares format but no enum list, and its type name is unqualified |
| Return type | ❌ never available | ✅ | responses carry only "OK" |
| Set-returning | ❌ never available | ✅ proretset |
not encoded |
| Volatility | ⚠ binary only, and only on PostgREST ≥ 13.0.5 — VOLATILE (POST-only) vs non-volatile (a GET exists); STABLE vs IMMUTABLE unknown. Below 13.0.5 the document emits a GET for every /rpc/ path, so castiron reports None — unknown — rather than asserting anything. See below |
✅ provolatile |
the GET is only gated on volatility from 13.0.5 onward |
is_read_only |
⚠ only on PostgREST ≥ 13.0.5 — a GET operation exists. Below 13.0.5 it is None, unknown, for every function; castiron will not tell you a mutation is read-only |
✅ provolatile != 'v' |
the same method gating, so the same version floor |
| Overloads | ❌ collapsed upstream — one arbitrary signature survives | ✅ | one path key per function name |
OUT / INOUT / TABLE params |
⚠ INOUT appears as an input; OUT params are excluded |
✅ | not encoded as inputs |
Functions are read but not yet emitted
castiron gen reports the function count it read (read 6 tables, 1 enum and 4
functions) and lowers them into the Schema IR, but no shipped emitter consumes them
yet. The typed RPC client is a later milestone — and, because return types are never
available from this source, a genuinely typed RPC client depends on the live-DB source.
Identity and generated columns¶
This is the limit you meet within thirty seconds of your first run — so it gets a warning, a flag, and this section.
PostgREST passes each column's default text through a JSON decoder and keeps it only if it
parses. nextval('orders_id_seq'::regclass) is not JSON, so it is dropped — and there is
no separate identity flag in the document. A textbook Supabase primary key:
create table orders (
id bigint generated by default as identity primary key,
...
);
therefore arrives at castiron as "NOT NULL, no default, not identity" — which is
exactly how a natural key like year int primary key arrives. The two are
indistinguishable in the document.
castiron will not guess, so by default it believes what it was told and marks the column required on the Insert model:
class OrdersInsert(CustomModelInsert):
"""Orders Insert Schema.
Customer orders.
"""
# Primary Keys
id: int
# Required fields
total: Decimal
user_id: int = Field(description="The customer who placed the order.")
That is annoying but safe: your code is forced to be explicit, and a wrong guess never
reaches your database. The alternative — silently assuming every integer primary key is
generated — makes year int primary key optional on insert and turns a compile-time
annoyance into a runtime constraint violation in production.
The warning¶
Because the fix is one flag away, castiron says so — once per run, on stderr, and only when the inference would actually change the output:
castiron: 4 tables have an integer primary key with no visible default (orders, products, restricted_table and 1 more) -- PostgREST does not expose nextval()/identity defaults, so castiron marks those columns required on the Insert models. Pass --infer-generated-primary-keys (or set infer-generated-primary-keys = true) if they are serial/identity columns.
If your keys are UUIDs, or composite, or genuinely natural, the warning never fires.
--infer-generated-primary-keys¶
If you know your integer primary keys are serial/identity columns — for a Supabase project they almost always are — turn the inference on:
castiron gen --from ./openapi.json --infer-generated-primary-keys
class OrdersInsert(CustomModelInsert):
"""Orders Insert Schema.
Customer orders.
"""
# Required fields
total: Decimal
user_id: int = Field(description="The customer who placed the order.")
id is gone from the Insert model, which is what you wanted.
The rule is narrow on purpose: it applies only to a sole primary-key column that is
NOT NULL, has no visible default, is not already flagged as identity, and whose type is
smallint, integer or bigint. It is still an inference — it is named as one, and it is
off by default, so nothing infers anything on your behalf unless you ask. Set it in your
config file once you have decided:
[tool.castiron]
infer-generated-primary-keys = true
Other consequences worth knowing¶
String lengths survive but do not become constraints¶
character varying(255) arrives with its maxLength, and the IR keeps it. But castiron's
Pydantic emitter derives Annotated[str, StringConstraints(...)] from length() CHECK
constraints, which this source never provides. So a varchar(255) column generates a
plain str:
email: str
Nothing is wrong and nothing is invented — the length is simply not expressed as a constraint in the generated model today.
Views¶
Every column of a view is reported nullable by PostgREST, so every field on a view model is optional:
class ActiveUsersViewBaseSchema(CustomModel):
"""ActiveUsersView Base Schema.
Users with a recent login.
"""
# Columns
email: str | None = Field(default=None)
favorite_product_id: int | None = Field(default=None)
id: int | None = Field(default=None)
PostgREST does propagate key markers through views, but castiron's IR defines a view as having no primary key — so the marker is retained one step down, as a UNIQUE constraint. That is a deliberate downgrade rather than a discard: it keeps the fact the document actually stated, and it is what tells a foreign key pointing at the view that the relationship is many-to-one rather than many-to-many.
Table or view is inferred from one signal¶
The document carries no view marker at all — PostgREST computes relkind IN ('v','m')
internally and never writes it down — so castiron infers the relation kind, and it infers it
from exactly one thing: the definition's required array.
required is non-empty → BASE TABLE
anything else → VIEW
That is the whole rule. It rests on a Postgres catalogue fact rather than on a PostgREST
behaviour: required is exactly the NOT NULL set, and a view column's
pg_attribute.attnotnull is always false, so a view never carries required.
Both directions of the inference can be wrong, and it is worth knowing how:
- A base table whose every column is nullable is classified as a
VIEW. Nothing in the document distinguishes it from one. - A view with at least one column reported NOT NULL would be classified as a
BASE TABLE. Postgres does not produce that shape today, which is why the rule is worth making, but the inference is what it is — the document is not being read for a fact it states, it is being read for a fact that correlates.
Misreading a table as a view has a bounded cost: a view has no primary key in castiron's IR,
so a <pk/> marker on it is retained one step down as a UNIQUE constraint (above). A base
table that lands in the ambiguous cell has no NOT NULL column at all, and a Postgres PRIMARY
KEY column is NOT NULL — so it has no primary key to lose.
Write verbs are not a signal. An earlier release also read post/patch/delete on the
relation's path as evidence of table-ness. Measured against PostgREST v12.2.3 and v14.14, the
verbs track Postgres auto-updatability, not relation kind: a GRANT SELECT-only simple view
is auto-updatable and gets all three. On the captured 26-relation schema the verb signal was
present on 24 relations including 3 of the 5 views. It was noise, and it is gone.
Privileges filter objects, not columns¶
This one runs in the unsafe direction, so read it even if you skip the rest.
PostgREST's default openapi-mode hides relations the API role cannot reach: a table your
key cannot read is absent from definitions, and a function it cannot execute has no
/rpc/ path. That filtering stops at the relation boundary. A column carrying a
column-level REVOKE is still described in its table's properties — measured against
PostgREST v12.2.3 and v14.14, and still present when openapi-mode = ignore-privileges is
set, so its presence is not an artifact of the privilege filter. It is simply not filtered.
REVOKE ALL ON public.partially_visible FROM anon;
GRANT SELECT (id, title) ON public.partially_visible TO anon;
secret_body is not readable by anon. It arrives in the document anyway, so castiron emits
a field for it:
class PartiallyVisibleBaseSchema(CustomModel):
"""PartiallyVisible Base Schema."""
# Primary Keys
id: int
# Columns
secret_body: str
title: str
A generated model is not a privilege boundary. It describes the schema PostgREST
published, not the subset of it your key may read — so a model can name a column that fails
with a permission error the moment you select it. Treat the models as a description of the
schema's shape; get "what may this role read?" from information_schema.column_privileges,
never from generated code.
A foreign key can point at a table you cannot see¶
PostgREST's privilege filter removes the relation, not the marker that references it. So a
column can carry <fk table='private_ledger' column='id'/> while private_ledger is nowhere in
definitions — the normal state of a locked-down project, not a misconfiguration.
castiron cannot build a relationship it has no target for, so:
is_foreign_keyisFalsefor that column. The flag means "castiron can build a relationship from this column", not "the database has a foreign key here".- The
FOREIGN KEYconstraint is kept in the IR, with itsFOREIGN KEY (ledger_id) REFERENCES private_ledger(id)definition. That is the record that the database really does have one, and it is what a later live-database run can be diffed against. - The column is emitted as a plain value. No nested model, no embed.
castiron genwarns once per run, naming up to three of them:
1 foreign key points at a table this schema does not contain (ledger_refs.ledger_id ->
private_ledger) -- the target is not visible to the API role, so castiron cannot build the
relationship. The column is emitted as a plain value and no nested model is generated for it;
the foreign-key constraint is still recorded in the IR.
If you did not expect that line, the API role is missing a GRANT on the target table. If you
did, nothing is wrong — the warning is telling you which relationship the models do not have.
Constraint names are manufactured¶
The document carries no constraint name at all — not for primary keys, not for unique
constraints, not for foreign keys. castiron fills in Postgres's own default spellings
(orders_pkey, active_users_view_id_key, order_lines_order_id_fkey) so that every constraint
has the stable key the IR needs.
Those names are right whenever the constraint was created without an explicit name, and wrong whenever it was not:
CONSTRAINT order_lines_order_fk FOREIGN KEY (order_id) REFERENCES public.orders (id)
castiron reports order_lines_order_id_fkey for that one. A guessed name is byte-identical to a
real default name, so nothing downstream could tell them apart — which is why the IR records the
provenance explicitly: every ConstraintInfo and ForeignKeyInfo this source produces carries
name_is_synthesized = True. A live-database source reads pg_constraint.conname and leaves it
False. Consumers are meant to read the flag rather than the string: compare constraint names
only when both sides report False, and omit name= from generated DDL when it is True.
Argument order is only partly encoded¶
This one used to run in the unsafe direction, because nothing about the result looked wrong.
A PostgREST document serializes one parameter list three ways, and the difference that matters is JSON's: a map has no meaningful order, an array does.
| Where | Shape | Order | Emitted for |
|---|---|---|---|
POST body properties |
object (map) | alphabetical | every function |
GET operation parameters |
array | declaration | STABLE / IMMUTABLE only on PostgREST ≥ 13.0.5 — every function below it |
POST body required |
array | declaration, non-defaulted arguments only | every function |
For search_products(p_terms text[], p_limit integer default 20) the body arrives sorted:
"properties": {
"p_limit": { "format": "int32", "type": "integer" },
"p_terms": { "format": "text[]", "type": "array", "items": { "type": "string" } }
}
...while the GET operation for the same function lists p_terms before p_limit. castiron
reads the ordered encodings, so it reports p_terms, p_limit — as declared.
What you get, and how to tell. Every FunctionInfo carries a parameter_order:
DECLARED—parametersis in declaration order, in full. Either a GET operation existed (STABLE/IMMUTABLE), or every argument was required sorequiredcovered the whole list, or the function takes at most one argument.DECLARED_PREFIX— the arguments without a default are in declaration order and come first; the defaulted tail is name-sorted. This is aVOLATILEfunction with at least one defaulted argument: there is no GET operation, andrequiredlists only the non-defaulted ones. Postgres forbids a defaulted parameter before a non-defaulted one, so what you have is a correct prefix followed by an unknown tail — never a scrambled middle.UNKNOWN— nothing was established: aVOLATILEfunction whose arguments are all defaulted, sorequiredis empty. The names are reported in alphabetical order, which is not a claim that the order is wrong — the function may simply be declared that way.
A live-database source reads pg_proc.proargnames, an ordered array for every function
regardless of volatility, so it always reports DECLARED.
Nothing shipped is affected today — no emitter consumes function parameters (see the note
above). It matters the moment one does: a client that generated a positional call from a
DECLARED_PREFIX function's full list would swap the defaulted arguments silently, which is
exactly why the IR states the limit rather than leaving it to be assumed. Call PostgREST
functions by argument name, which is what the JSON body is anyway, and the order never matters.
Below PostgREST 13.0.5, order is recovered in more cases, not fewer
An old server emits the GET operation for every function, including VOLATILE ones — so
DECLARED_PREFIX and UNKNOWN largely disappear and you get full declaration order even for
a mutation. That half is a genuine gain; the volatility signal is what you lose, and the next
section is about that.
Volatility depends on your PostgREST version¶
This is the one limit on this page that is a property of your server build rather than of the document format, so it is the one you can fix by upgrading.
PostgREST decides a function's path item in makeProcPathItem
(src/PostgREST/Response/OpenAPI.hs). Until 13.0.5 that decision did not exist:
-- v12.2.3, v12.2.12 (the last 12.x) and v13.0.4 — UNCONDITIONAL
pe = (mempty :: PathItem)
& get ?~ getOp
& post ?~ postOp
-- v13.0.5 and later — GATED ON VOLATILITY
pe = case pdVolatility pd of
Volatile -> (mempty :: PathItem) & post ?~ postOp
_ -> (mempty :: PathItem) & get ?~ getOp & post ?~ postOp
The change is PR #4174, released in 13.0.5 (2025-08-24) as "Fix OpenAPI specification incorrectly exposing GET methods for volatile functions".
So the floor is 13.0.5, not "13", and not "not 12". Releases 13.0.0 through 13.0.4 are affected too, and no 12.x release carries the fix — the 12.x line ends at 12.2.12 (2025-05-01), which predates it. Self-hosted Supabase and long-lived deployments do run these.
What castiron does below the floor. For every function:
FunctionInfo.volatilityisNoneandFunctionInfo.is_read_onlyisNone— unknown, both of them, together.Schema.postgrest_versionrecords the document's verbatiminfo.version('12.2.3 (519615d)','14.14'), so a consumer can tell "unknown because the format cannot say" from "unknown because the server is too old". It is provenance only: no emitter writes it into generated code, so upgrading your server never shows up as drift incastiron check.castiron genprints one warning naming the observed version and the affected function count.
Everything else in the document is unaffected — tables, columns, nullability, enums, foreign keys, argument names, argument types and argument order are all exactly as good as they are on a current server. Only those two fields degrade.
Why unknown and not "assume VOLATILE". Assuming VOLATILE would be exactly as unfounded as
the old behaviour's implicit assumption of non-volatile; castiron does not guess in either
direction. The asymmetry that matters is in the consequences: is_read_only=None makes a consumer
fall back to POST, which is correct and merely unoptimised, while is_read_only=True for a
mutation is a GET request for an INSERT.
The remedy is to upgrade PostgREST to 13.0.5 or later. (A live-database source reads
pg_proc.provolatile and never has this problem — it is on the roadmap.)
Integer widths depend on your PostgREST version¶
This is the second version-scoped limit on this page, and it runs the opposite way from the one above: here the older server tells you more.
From PostgREST 14.8 an integer's format is Swagger's own int32/int64, so smallint and
integer are the same token in the document and only bigint stays distinct. Below 14.8 the
field carries the Postgres type name and all three are distinguishable:
| Column declared | PostgREST ≥ 14.8 | PostgREST < 14.8 |
|---|---|---|
smallint |
"format": "int32" |
"format": "smallint" |
integer |
"format": "int32" |
"format": "integer" |
bigint |
"format": "int64" |
"format": "bigint" |
The change is PR #4641 (2026-04-03), "Fix
invalid OpenAPI 2.0 format for integer types" — OpenAPI 2.0 defines int32/int64 as the
formats for type: integer, and a raw Postgres type name there is not legal. Diffing a full
v14.14 document against a v12.2.3 one for the same schema, key order included, gives 49
differing values: 43 column properties and 6 function parameters, and nothing else.
castiron reads both spellings correctly, with no special case. int32/int64 are aliased to
integer/bigint, and every type map already keys smallint, integer and bigint directly —
as does the surrogate-primary-key inference, which tests membership of {smallint, integer,
bigint}. Your generated models do not depend on which server answered.
What does depend on it is what castiron can know. On ≥ 14.8 the width below bigint is
genuinely gone. For the Pydantic emitter that is invisible — both resolve to int — but it is
real, and it will matter to an emitter that cares about storage, such as a future SQLAlchemy
emitter choosing between SmallInteger and Integer.
14.8 is not 13.0.5 — there is no single 'minimum PostgREST'
The volatility floor is 13.0.5; this one is 14.8. They are unrelated upstream changes pointing in opposite directions, so no one version is best at everything: below 13.0.5 you get exact integer widths and no volatility; from 13.0.5 to 14.7 you get both; from 14.8 you get volatility and the widths collapse. castiron scopes each fact to its own version and asks you to do the same.
Unknown types never fail¶
A type token castiron does not recognise is recorded verbatim and resolved to Any rather
than aborting the run. You get models, and the unrecognised column is honestly untyped.
When to upgrade¶
Use the OpenAPI source when you want reach: no credentials, no driver, CI-friendly, and a document you can save and replay offline. That covers most day-to-day model generation.
Reach for a live-database source when any of these are load-bearing for you:
- UNIQUE, CHECK or EXCLUDE constraints (and the validation you would derive from them)
- identity/generated columns as fact rather than inference
- exact integer widths (only lost on PostgREST ≥ 14.8 —
why), numeric precision/scale,
varchar(n), domain types - composite foreign keys, composite primary-key order, cross-schema relationships
- function return types, set-returning functions, overloads, or the declared order of a function's arguments
- function volatility, if you are stuck on a PostgREST below 13.0.5 (why)
- anything your API role cannot see
The live-database source is on the roadmap and is not shipped yet. Until it lands, this
page is the complete list of what a generated model can and cannot be trusted to know.
Every claim above about castiron's behaviour is asserted by a test under tests/unit/
(sources/openapi/ for the parser, corpus/ for the emitted bytes), so the floor cannot
move quietly; the claims about PostgREST's behaviour were measured against a live
Postgres + PostgREST apparatus on two versions, because no test castiron owns can pin
somebody else's server.