Documentation/Under the hood/When a language model writes your schema, the resolver has to be stricter
When a language model writes your schema, the resolver has to be stricter
Three things a schema system needs once a model is authoring the schemas: synonym folding at write time, refusing to guess an ambiguous base, and telling hard dependency edges from soft ones.
- schemas
- ai-generation
- data-modelling
- inheritance
- correctness
Overview
A human picks a field name once. A model picks a plausible one every time.
---
Generated applications changed the failure modes of our schema system, and not in the direction anyone expected.
A human data modeller is inconsistent across months.
A model is inconsistent across paragraphs. Ask one for a product catalogue and a listings page in the same session, and you can get `cover_photo` in one schema, `image` in the next and `photo` in the third, every one of them a name a competent engineer might have chosen, all three meaning exactly the same thing and none of them agreeing with the base type that already had a field for it.
Nothing about that is wrong, exactly. It is just that a system built on the assumption that a name is chosen deliberately now receives names that are chosen fluently.
Three things had to change. Each one is a case of choosing between guessing and refusing.
1. Synonyms fold, at both ends
A base type can declare that an attribute answers to more than one name. The canonical attribute is `primary_image`, and it answers to eight aliases including `cover_photo`, `image`, `photo` and `hero_image`. Eight names, one field, one set of access rules.
Two things then happen, and the second one is the one that matters.
An extending schema that declares `cover_photo` **folds into** the canonical attribute. The resolved schema exposes one `primary_image`, not two fields that happen to mean the same thing. So a generated schema using a synonym inherits the base's validation, its access rules and its indexing, rather than quietly creating a parallel untyped field beside them.
And a *record* written with `cover_photo` as its key is **stored under `primary_image`**.
That second one is the load-bearing half. Without it, a generated application whose forms use the synonym writes data that the resolved schema cannot see, and the field appears empty in every admin view while the data sits in the document under a different key. Folding at schema time alone would have produced exactly that.
An alias that collides with a real attribute name, or that two different attributes both claim, is rejected at upload. There is a right answer for a duplicate name and it is not to pick one.
2. An ambiguous base does not resolve. Ever.
Inheritance is by name, and names are not globally unique. Two namespaces can each hold a `base_item`.
So the resolver keeps two indexes. One is keyed by the fully qualified `namespace:name` and is authoritative. The other is keyed by the bare name, and exists only so that older schemas written before qualified references were the convention still work.
An unqualified `extends` is resolved in a defined order. The extending schema's **own** namespace is preferred first, which is almost always what the author meant. Only if that misses does the bare-name index get consulted.
And if the bare name appears in two or more namespaces, it is marked ambiguous and **never resolves at all**.
Not "resolves to the first one found". It does not resolve, and the schema extending it comes back unmerged.
This is a deliberately unhelpful behaviour and it is the right one. The alternative is a schema that silently inherits from a base belonging to a different application, picked by index order, and then behaves correctly in testing for as long as only one of the two namespaces exists in that environment. Refusing is visible. Guessing right most of the time is the worst possible outcome, because it trains everyone to trust it.
The fix in the schema is one word. Qualify the reference.
3. Hard edges gate cycles. Soft ones do not.
Cycle detection sounds like a solved problem until you meet a data model where a cycle is correct.
A dentist references a clinic. A clinic references its dentists.
That is an ordinary bidirectional relationship. It is what anyone would model. A naive cycle check rejects it.
So dependency edges are split. A **hard** edge is one where the schema genuinely cannot be created before its target: a parent type, an attribute whose type is another schema, an enum or type base. A **soft** edge is a reference between two records. Those can be created in either order and linked afterwards.
Only hard edges gate cycles. A cycle in hard edges is reported with the actual path, so you get the chain rather than a boolean.
The ordering algorithm had to learn the same distinction, and this is the part that took a second attempt.
Schemas are applied in dependency order by a topological sort. Counting soft edges in a schema's in-degree meant the members of a legal reference cycle never reached zero, so they never came off the queue, and they fell through to an arbitrary-order fallback that could place a schema before the base it actually extends. The symptom was an upload that failed for a reason unconnected to the cycle.
The part we are least happy with
Resolution is **fail-soft**.
If anything throws while merging inheritance, the unresolved list is returned rather than a `500`.
The reasoning is not unreasonable: a schema listing that half-works beats a schema listing that errors, and the client can still read the raw definitions.
The consequence is that a client can receive an unmerged schema and not be told. A field that should have been inherited is simply absent, and absent looks like "the base did not declare it" rather than "the merge failed".
A representation that can silently differ from the declaration is a bad thing to hand a caller, and the fix is not subtle: the response should carry a flag saying resolution degraded, so a client can tell a schema with no inherited fields from a schema whose inheritance did not run. That is on us and it is not done.
There is a performance edge in the same area. Asking for resolution disables a targeted-fetch optimisation. Merging inheritance needs the whole domain's schemas loaded rather than the two you asked for. Resolution is the expensive path, by construction, and it is worth knowing before you put it behind a request that runs on every page load.
One last trap, and it costs an hour when you hit it: an `extends` declaration must live inside the schema's content, not beside it. A top-level `extends` on the wrapper is stripped, and the schema uploads successfully with no inheritance and no complaint.
What this suggests for anyone generating schemas
None of these three is really about language models.
**Decide once, server-side, at the boundary.** Aliases fold at write time, not in every reader. Ambiguity is resolved by refusing at resolution time, not by each client picking. Cycles are classified at upload time, not rediscovered by whatever creates records first.
Every one of those could have been left to the caller. Each time, "let the caller sort it out" means every caller sorts it out slightly differently, and a generated caller sorts it out differently every time it is generated.
**Refuse rather than guess, and say which.** The three refusals above produce an error a person can act on within a minute. The three corresponding guesses would each have produced a system that works in development and is wrong in a way nobody can reproduce.
*(Disclosure: I work on Supero. Our schemas arrive from a model rather than a person, which is why I noticed any of this. Every behaviour above is in the platform today, including the fail-soft resolution I would rather was fail-loud.)*
On this page