Repository navigation
1.0.5
All four registries publish: npm 1.0.5 (full lockstep across all 14 @metaobjectsdev/*
publish candidates), Maven Central 8.0.5, PyPI 1.0.5, NuGet 1.0.5 — every port has changed
product code.
metamodelVersion stays 1.0. The requirement.* change below is a prose-only manifest
edit (two registered description strings) and was ruled a hold, as 1.0.4's was.
Upgrading — read these three before you regenerate.
meta genis required (dotnet meta gen/mvn metaobjects:generate/
metaobjects gen), and for anyone using templates the diff is large: a value object is
now generated once, at its own location, and the template-named copies are gone
(ADR-0056, #387). Wire the value-object generator in any run that wires a template-tier
generator, drop thepayloadgenerator, and delete the files that are no longer emitted —
meta gennever deletes one, andmeta verify --codegennames them. Full list:
migration guide.- Two metadata shapes stop loading. (a) An M:N junction whose two
identity.referencechildren cannot be PAIRED to the two entities it joins is now an
error in every port, where before it loaded clean and then behaved differently per port.
If your junction loads today it is unaffected; if it does not, the error names the
reference. (b) A child wrapper whose body is not an object —
{ "field.string": "label" }, or a{ "$comment": "…" }entry used as a comment — is
nowERR_CHILD_NOT_OBJECT. It never built a node in any port; it was a warning in two,
an slf4j line in one and silence in Python, so the field was simply absent from the
generated code. The error names the wrapper and its JSON path; give it a node body or
remove the entry. - The filter/sort 400 envelope gained a required
fieldmember in every port. Consumers
gain a key and lose nothing, but a hand-written caller of the JVM/C#FilterParseResult
needs one edit.
Changed
- Generators are reference helpers, not guarantees (ADR-0034 Amendment 3). What
MetaObjects promises is the core: the metamodel, loader, canonical format and registry;
runtime metadata access;meta migrate;meta verify; prompt render and the reply
parser. Every generator that writes application code into your repo is a starting point
that compiles and passes its reference fixtures, which you copy and own. The catalog now
says so at the top ofmeta gen --list,dotnet meta gen --listandmetaobjects gen --list; the README, the docs and the shippedmetaobjects-codegenskill say so too.
Every port can now eject a generator:meta eject(TypeScript), and new in this release
metaobjects eject(Python),mvn metaobjects:eject(Java and Kotlin) anddotnet meta eject(C#). That
supersedes ADR-0035 §3's ruling that owning codegen through build configuration was
enough. Nothing an existing project runs changes.
-
A responding
template.promptnow gets a generated render helper in C#, Java, Kotlin
and Python — previously only TypeScript did. Each of those four ports'render-helper
generator filteredtemplate.output, so an adopter could parse a model's reply from
generated code but had to call the render engine by hand to produce the prompt that
elicited it. ADR-0052 makes the template subtype axis DIRECTION, and both directions
RENDER — only what comes back differs — so the filter was reading the wrong axis. The
generator now emits for every renderable template; a prompt carries no@kind, so it
takes the document shape and the email branch stays output-only. TypeScript is unchanged:
there a prompt's render handle is the separateprompt-rendergenerator, and that split is
now stated infixtures/generator-registry-conformance/registry.jsonrather than implied.
Generated-output change — regenerate to pick it up; three-way merge preserves hand
edits. -
Versioning: the generator tier is CONVENIENCE, and convenience is always a PATCH
(ADR-0035
Amendment 4). Adding a generator, changing what one emits, or rewriting a reference
template is a PATCH in every case, however large the diff — because scaffold-and-own means
an adopter holds a COPY and takes our edit only by re-runningmeta genand accepting the
diff, which is precisely the opt-in a MINOR exists to signal. What still moves the package
number: the codegen ENGINE's surface, a runtime library's surface, CLI semantics. What
still movesmetamodelVersion: registered vocabulary, the canonical/interchange format,
the wire contract. This supersedes three rows ofdocs/RELEASING.md's change-class table
and the litmus test above it, both of which reasoned from "did generated output change".
The changelog convention is untouched and matters more now: an output-changing release
still says so, because the changelog rather than the version number is how an adopter
learns a regen is worth running. -
A template's payload and response types are now its value objects' own types, in every
port — thepayloadgenerator is removed (ADR-0056,
#387). This changes generated output for every adopter who uses templates. Each port used
to generate a value object twice: its own type, and a template-named copy
(<Template>Payload,<Template>Response,<Short>Payload, theprompts.tsinterfaces)
written into the template's package. In Java and Kotlin, a nested value object shared by
templates in two packages landed in whichever package sorted first, and the other package
did not compile. Now the value-object generator — TSentity, C#entity, Java
value-object, Kotlinentity, Pythonentity— emits one type per concrete
object.valueand sourcelessobject.projection, at the value object's own location,
and render helpers, parsers, extractors and output-format fragments reference it.Adopters must regenerate, update their imports, and wire the value-object generator
in any run that wires a template-tier generator. Removepayloadfrom C#/Python
--generatorsandSpringPayloadGenerator/KotlinPayloadGeneratorfrom Maven
<generators>. Delete generated files that are no longer emitted (meta gennever deletes
one;meta verify --codegennames them) — including a TSprompts.tsthat held only
interfaces. Types are named after the value object; where a port emits every value object
into one flat namespace (TS, C#, Python), a colliding short name is package-qualified
(AcmeAlphaNote). The lenient mirror is<Vo>Extracted. Other visible changes: TS
optional fields arename?: T(notT | null) and the removed exports are
generatePayloadInterfaces,generatePayloadInterfacesBatchandgenerateRenderHandle;
C# payloads are the<Vo>.g.csPOCOs with PascalCase members, and the extractor is
<Prompt>Extractor; Java drops the generatedhasFoo()methods (the render engine derives
them) and now collidesentitywithvalue-objecton every value object; Kotlin's strict
parser decodes with Jackson; the Java, Kotlin, C# and Python output-format root name is the
value object's short name, so rendered prompt text changes; Python's request model no
longer rejects unknown keywords. Full list:
migration guide. -
The filter/sort 400 envelope now NAMES the rejected field, in every port —
fieldis
REQUIRED, not optional.invalid_filter_field,invalid_filter_op,
invalid_filter_valueandinvalid_sorteach carry
{"error": "<code>", "field": "<name>"}. Before this, TypeScript sentfieldand the
other four ports did not, and nobody could tell: every api-contract corpus assertion is a
deep-SUBSET match, so TS's extra key passed a corpus the other four satisfied without it.
That is the actual defect being fixed — an "optional" member is un-gateable. The
corpus can neither require nor forbid it, so the divergence was permanent and invisible
rather than merely undecided. Ruled additive: adding the key is PATCH-safe and names what
was rejected, where deleting it from TS would be breaking and would lose a diagnostic.Consumers gain a member and lose nothing — no existing key changed and no status code
moved. Adopters with committed generated code should runmeta gen(dotnet meta gen
/mvn metaobjects:generate/metaobjects gen): C#, Java, Kotlin and Python all emit a
changed list handler, and the JVM/C#FilterParseResultgains afieldmember (err(...)
/Err(...)now take it, so a hand-written caller of that runtime type needs one edit).
TypeScript needs no regen — its routes delegate to@metaobjectsdev/runtime-ts.Gated, not just shipped:
fixtures/api-contract-conformance/scenarios/assertsfieldon
all four codes, in both the reference and generated lanes of all five ports, and a new
filter-invalid-valuescenario covers the one code no scenario reached before (it uses
?filter[<f>][isNull]=maybe, the one operator whose value every port coerces in its own
parser). A code that is not about a field — filter nesting depth, in-list size — carries
nofieldand stays outside the contract. -
@metaobjectsdev/runtime-ts's Hono mounts now emit the cross-port wire code, as the
Fastify mounts always did. They were sending the library's INTERNAL dotted code, so the
same metadata answered{"error":"filter.unknown_field"}on Hono and
{"error":"invalid_filter_field"}on Fastify — contradicting this package's own
documented guarantee that the two flavors are byte-identical on the wire. Found while
implementing thefieldchange above. The projection (read-only) mounts on both flavors
also now include the parser's details, so a view endpoint names the field too. -
BREAKING (metadata): a M:N junction that cannot be PAIRED is now a load error in every
port — previously-loading metadata stops loading.@throughhas always been documented
as a junction declaring "twoidentity.referencechildren, one per FK side", but the
loader only ever counted them; it never checked what they point at. A junction whose two
references name some other pair of entities therefore loaded perfectly clean, and then
behaved differently in every port: TypeScript and C# warned and emitted no traversal route
at all — the endpoint simply 404s, with nothing in the build output a CI gate would fail
on — while Java, Kotlin and Python threw and failed the build. One input, two contracts,
and the quiet arm was the dangerous one. Owner ruling 2026-09-20: the MODEL is what is
wrong, so every port now rejects it at LOAD withERR_INVALID_RELATIONSHIP.This is a correction, not a redesign, and it ships as a PATCH under the three-part test
indocs/compatibility-policy.md: the form contradicted the documented contract; it
produced no correct outcome for anyone, in any port (a traversal that 404s is not a
working outcome); and the load error names the exact repair — which entity, which
relationship, which junction, and which reference is missing.metamodelVersionstays
1.0: this lives in loader validation and has no registry-manifest footprint, which is
precisely the class the version gate cannot see. If your model has such a junction it
will stop loading, and the fix is to point one of itsidentity.referencechildren at
the navigating entity.The loader runs the REAL FK derivation and converts its failure, rather than
re-implementing the pairing — the defect class being closed here is "two parts of the
pipeline disagree", so a second opinion would have been the wrong shape. Scope mirrors
codegen's own iteration exactly: every concrete, non-projection object crossed with its
effective relationships, NOT deduped by declaration, because pairing is a property of the
navigating entity and an inherited M:N can pair from one subtype and not another. Gated
cross-port byfixtures/conformance/error-relationship-m2m-junction-unpairable/. -
A cross-package M:N bound the WRONG junction in TypeScript and C#. Found by the rule
above, which made it fail loudly instead of silently. Both ports resolved@through
through a bare-name lookup that a package-qualified name could never match, then fell
back to the first entity with that short name — so with two same-short-named junctions in
different packages (the shapexpkg-m2n-collisionhas covered all along) the derivation
paired against the wrong one and gave up, emitting no traversal route for a perfectly
valid model. Java was already FQN-exact here and unaffected. All four now resolve through
the one ADR-0041/0042 matcher the loader itself uses, which also closes the deferred
bare-name collision gap (#174): a bare@objectRef/@throughnow binds in the
referrer's package instead of whichever same-named entity happened to load first —
load-order dependence, not a naming rule. A junction reference's@referencesresolves in
the junction's package and the relationship's own@objectRefin the declaring entity's,
because underextendsacross packages those differ. -
BREAKING (generated routes): the REST collection URL is now the entity name
snake_cased then pluralized, in every port. One rule replaces five. The ports
agreed only on single regular words —Author→authorsunder every old rule, and
every collection base in every corpus was such a word, so all five lanes were green
whileOrderSummarywas served at four different URLs:/order_summaries(TS
entity),/order-summaries(TS projection),/ordersummaries(C#) and
/ordersummarys(Java, Kotlin, Python). Kotlin's own comment asserted all four ports
shared one trivial rule; only Java matched it.Who is affected. If every entity and projection name in your model is a single
regular word, nothing moves —authors,posts,tags,persons,accounts,
auths,documents,ordersare byte-identical under the old and new rules. If any
name is multi-word or takes an irregular plural, its collection URL changes and
clients calling it must follow. Regenerate (meta gen,dotnet meta gen,
mvn metaobjects:generate,metaobjects gen) and diff your routes. This is a
reference-template output change — codegen is opt-in and generated code is yours
(docs/compatibility-policy.md) — so it ships here rather than waiting for a major.The rule:
s/x/z/ch/shtakeses, a consonant beforeybecomesies(a
vowel does not —days), and a run of capitals stays together until the final one
that begins a word (HTTPServer→http_servers). It derives from the entity NAME,
never the physical@table. TypeScript's deliberate entity-vs-projection split is
collapsed into it, soOrderSummaryis/order_summarieseither way.It is gated rather than merely documented:
fixtures/api-contract-conformance/m2m/
declaresPostCategory— multi-word AND ending consonant+y, so one entity
separates every spelling the ports used to produce — and asserts that both retired
spellings 404, on each port's hand-rolled reference lane AND its generated lane. The
JVM ports now share ONE implementation (RouteNamingincodegen-base) instead of
two hand-maintained copies; Python's second private copy of the rule in
m2m_codegenis gone the same way. The acronym case is pinned by unit test per port,
since no corpus entity carries one. -
meta types <construct> --detailno longer drops rows from a query that has already
narrowed.meta types field.enum --detailprinted "20 of 22 shown — narrow with
QUERY/--type/--kind or raise --limit", having dropped@valuesand@xmlText. Both halves
were wrong: the rows were lost, and the advice named three ways to narrow a query that
already names one construct and cannot narrow further. The cap is a BROWSE control — it
exists so a baremeta typesdoes not dump 518 rows — and a query resolving to a single
construct is the drill-in the command exists for, bounded by that construct's own attr list.
The DEFAULT cap now yields there; an explicit--limitis an instruction and is still
obeyed exactly. -
The registered
requirement.functionalandrequirement.architecturaldescriptions
now say whatmeta verifyenforces. They said a functional requirement "fails when
nothing implements it" and an architectural one "fails when something VIOLATES it". In
fact alive/partialfunctional claim that names nothing is a warning
(WARN_REQUIREMENT_NOTHING_IMPLEMENTS, exit 0), and only a named node that no longer
exists fails (ERR_REQUIREMENT_DANGLING_REF). The architectural check fails a live policy
applied to nothing (ERR_REQUIREMENT_ARCH_NO_IMPLEMENTERS) and never checks that a claimed
node complies. Every port's embedded copy, the registry-conformance manifest, the
metamodel-docs fixture, the requirement harness and the published reference pages carry
the corrected text. Behaviour is unchanged. The README,llms.txtand the audit skill
also stop saying theverifychecks run in every port: the vocabulary loads and
validates in all five, and the checks run in the NodemetaCLI.
Added
-
C#:
dotnet meta eject <name>...copies a reference generator's source (shipped
embedded inMetaObjects.Codegen) intocodegen/generators/, renaming only its namespace,
and on first use scaffolds an ownedcodegen/console project that lists the generators
and calls a new public runner (CodegenCli), whichGenCommandnow uses too. Once that
project exists,dotnet meta genanddotnet meta verify --codegenhand off to it. The
owned project keeps the rest of your selection: an owned copy replaces the packaged
generator of the same name, and every other name in--generatorsstill runs from the
package. It finds metadata the way the tool does,librariesincluded, and the scaffold
pinsMetaObjects.Codegenat the tool's published version, pre-release suffix included.
(The metadata-location ladder moved intoMetaObjects.Config.MetadataLocationso the tool
and an owned project share it.) Eject
never overwrites a copy without--forceand never edits the scaffold after writing it;
--listmarks owned copies. The helpers a copy needs are now public (Fr010FieldMapping,
FindInbound,ExtractDelegateEmitter,OutputFormatSpecEmitter,
ValueObjectNames.Namesand others). -
Java and Kotlin:
mvn metaobjects:eject -Dnames=<a,b>copies reference generators
into acodegen/Maven module under your own package, since the plugin's own class wins
over a same-named one and nothing in the module being generated is compiled yet at
generate-sources. It writescodegen/pom.xmlon first use, never edits an existing pom,
and prints the<module>, plugin dependency and<classname>to wire.-Dlistprints the
catalog with owned copies marked. The written pom builds on its own: it sets Java 21 (and
Kotlin's JVM target 21) and pinskotlin-maven-pluginto the Kotlin version the plugin
was built with, so an unedited copy compiles without a parent pom managing either. Every
Kotlin generator is ejectable; Java'sentity,
extractorandtemplateare not. To make every copy compile against published
artifacts,SpringRecordBuilder,OutputFormatSpecEmitter,
SpringTypeMapper.isLenientNetField,KotlinEnumEmitter,Fr019SharedEnum,
KotlinExtractSchemaEmitterandKotlinOutputFormatSpecEmitterare now public. -
Python:
metaobjects eject <name>...copies a reference generator into
codegen/generators/to own and edit. Wire the copy ingenerators(or--generators) as
module:symbol, the formprovidersalready takes;genandverify --codegenthen run
your copy. Eject never overwrites a copy without--forceand never edits the config, and
metaobjects gen --listmarks owned copiesidenticalorDIFFERS: N behind, M of your own. An unchanged copy generates byte-identical output to the packaged generator. -
A view-only projection has a REST surface in all five ports. An
object.projection
whose only source is a read-only view used to get routes in TypeScript and C# and nothing
at all in Java, Kotlin and Python — a 2-vs-3 split that was documented per port rather
than decided, because no corpus covered the one shape that diverged. Every port now
emits a READ-ONLY surface: GET list and GET by id with the filter and sort allowlists
built from the projection's own fields, and POST / PATCH / PUT / DELETE each answering
405 {"error": "method_not_allowed"}(405, not 404: the same path answers GET). The
refusals are mounted explicitly in every port, because left to the framework ASP.NET and
Spring answer with an EMPTY body and FastAPI with{"detail": …}. A keyless projection
mounts no/{id}read and so refuses only the collection verb. Gated by the new
fixtures/api-contract-conformance/projection/sub-corpus (seven scenarios), on the
generated lane in all five ports.What an upgrade asks of you, per port:
- Java — action required. Each projection now has a generated controller and a
read-only<Projection>Repositoryof three methods (list,count,findById).
Spring's component scan picks the controller up, so until a bean implements that
interface the application does not start (required a bean of type '…<Projection>Repository' that could not be found). - Kotlin — nothing to do: the controller reads through the generated Exposed object.
- Python — a new
<projection>_router.py; it serves nothing until you include it. - C# — the routes you already mount now refuse writes with the envelope instead of
ASP.NET's bodiless 405. - TypeScript — the read-only mount now also refuses PUT (it fell through to a 404).
- Java — action required. Each projection now has a generated controller and a
-
GET /_meta— a per-port metadata endpoint contract, plus a browser read-model that
consumes it. Every backend can now serve its loaded model as a single
GET {apiPrefix}/_metadocument: the response body is that port's effective
canonical JSON (the super-chain merge already materialized) — the same serialization
each port's metadata conformance corpus already pins, never the raw form, so a browser
reader never has to resolveextendsitself (per ADR-0039, an own-vs-resolving mistake
there silently drops inherited@columns/@pageSize/@sortableDefaultOrder). Each port
ships a thin, framework-free helper alongside its ownMETA_ROUTE_PATHconstant
("/_meta"): TypeScript'smetaJson(root)(@metaobjectsdev/runtime-ts), C#'s
MetaEndpoint.MetaJson(MetaData root), Java'sMetaEndpoint.metaJson(MetaData root)
(reachable from Kotlin through themetadata-ktxfacade), and Python'smeta_json(root).
Only TypeScript ships an HTTP mount —mountMetaRoute(Fastify) and
mountMetaRouteHono(Hono), both in@metaobjectsdev/runtime-ts: it is the only port
with a web-bound runtime home, and adding a web-framework dependency to a runtime package
for one endpoint would push that dependency onto every consumer of the package, including
ones with no HTTP surface at all — every other port's helper is mounted by the host.
Mounting is opt-in everywhere; nothing registers the route implicitly, and/_meta
should sit behind whatever auth the rest of the API uses, since it discloses the model's
shape (entities, fields, types, validators, layouts — never row data).On the browser side,
@metaobjectsdev/runtime-webshipsloadMetaModel(), a plain
structural reader (no registry, no validation, noextendsresolution) that turns a
fetched/_metadocument into the narrowMetaRead/MetaFieldRead/MetaLayoutRead
/MetaModelReadsurface the existingbuildGrid()already consumes — so a grid built
from a live document and one built from a real loadedMetaObjectproduce byte-identical
output, gated by a parity suite that runsbuildGrid()over both. Two TypeScript barrels
widened to support it: the@metaobjectsdev/metadatapackage root now exports
canonicalSerializeEffective, and its browser-safe/constantssubpath now re-exports
shared/base-typesandshared/structuralas well.See
docs/features/metadata-api.mdfor the full contract, per-port code samples, and the
browser-consumption walkthrough. -
Independent checks and a feature-combination gate (TypeScript, real Postgres). Every
corpus was green while the first real application found nine defects, each where two
features meet, and two of them were invisible by construction:meta verify --dbdiffs
against the same builder that wrote the migration, and a dropped.references()
compiles. A new oracle inintegration-testsrestates from the metadata alone which
table stores each object's rows, its columns and FKs, and the routes served, and reads
the answer frompg_catalog, Drizzle's own table config and the booted app. A pairwise
covering set of 23 models across six feature axes runs through generate,tsc(routes
included), migrate and boot under it. Recorded defects are a signature-matched ledger
that fails when an entry stops reproducing. Seedocs/CONFORMANCE.md.bun run --cwd server/typescript/packages/integration-tests oracle <project-dir> <postgres-url>asks the same oracle of any project and database; run against an adopter
estate migrated by1.0.5-rc.1, it named the three FKs that release dropped. -
Codegen-compile conformance (TypeScript lane). A new gate generates from the shared
cross-port corpus (fixtures/persistence-conformance/canonical/meta.fitness.json— 16
entities, 2 view-backed projections, two M:N through-junctions, a 4-entity TPH hierarchy,
jsonb storage,isArray, currency, decimal, and anAllTypesentity carrying every
persistable field subtype) and compiles every emitted module with the real TS compiler, on
both dialects. The existing corpora gate BEHAVIOR; none of them asked whether the emitted
code builds, which is why four separate "generated code does not compile" defects shipped
in 1.0.4 with every gate green. It found three more on its first run — all three are fixed
below. -
Codegen-compile conformance in the four remaining ports. The same gate, the same
corpus, the same question, now asked by C#, Java, Kotlin and Python — so all five ports
compile what they emit. Each uses its real compiler rather than a shared one: Roslyn
(zeroDiagnosticSeverity.Error),javacviaToolProvider,KotlinCompilation, and —
Python having no static compiler — importing the generated package, which is what resolves
the package-relative imports BETWEEN modules, plusruffF821 for the branches importing
never executes. C# and Java each run two selections rather than one, because a single
selection is not a coherent build in either (C#'sIncludeNameshas a recorded CS0103;
Java'sentityandvalue-objectboth emit a type for anobject.value). Every port
excludes its framework-bound route tier — TSroutesFile, C#RoutesGenerator, Java
SpringControllerGenerator, KotlinKotlinSpringControllerGenerator: those imports are
not on an in-memory compile's classpath and stubbing them drowns the signal, so that tier
is compiled and booted over real HTTP in the api-contract lane instead. One cross-port
rule, not four local concessions. The fan-out found five further real defects, fixed below.
Fixed
-
C#:
dotnet meta verifyrejected@descriptionon every node, including the shipped
iamandailibraries' own metadata. The loader's default registry
(CoreTypes.LibraryProviders) never composed the documentation provider, so the common
attrs it registers —@description,@title,@summary,@notes,@deprecated,
@replacedBy,@seeAlso,@aliases— were unknown to it.dotnet meta genloads lax
and let them through unvalidated;verifyloads strict and failed with
ERR_UNKNOWN_ATTR, so any project opting into a library could not run the C# drift gate
at all. The conformance corpora never saw it: every C# test composed the full bundle
explicitly, including the documentation provider, while the comment on that bundle said
it was "the same composition the loader's default registry uses". It was not, and a test
now fails if the two differ. Every other port already composed the provider by default.
Found by runningdotnet meta verify --codegenagainst an adopter estate. -
genno longer records output written outside the project in the project's
.hashes.json— TypeScript, Python and C#. The manifest keys every generated file by its
path relative to the project root, so an output directory outside it (a scratch preview,
a sibling checkout) was committed as../../<elsewhere>/X— 118 such keys on one adopter
estate, from preview runs nobody remembered. In TypeScript it was worse: the snapshot body
is stored at<gen-state>/<key>, so the../..walked out of.gen-state/and wrote a
second full copy of the output into the project tree. Such a file is now written with the
rules a run with no project already had (no manifest entry, no snapshot under the
project), andgensays so in one warning. Delete any../-keyed entries from a
committed manifest by hand; nothing reads them. -
Python: the
entitygenerator's catalog entry and the install docs now name
pydantic[email]. A field with@stringFormat: email— the shippediamlibrary's
User.emailis one — is typedEmailStr, which importsemail-validatorwhen the model
class is defined. The documented install waspip install "pydantic>=2" fastapi, so a
clean install of a project usingiamfailed at import with nothing pointing at the
cause. Found building a second virtualenv for an adopter estate whose first one had the
package installed by hand. -
C#: a
verify --codegenload failure now says what failed. It printed the error
CODES alone —metadata did not load cleanly (ERR_UNKNOWN_ATTR, ERR_UNKNOWN_ATTR, …),
27 of them on the estate above — with no attribute, node or file. It now prints each
error's message, which names all three. -
Java: an int-backed
field.enumgenerates its declared value map. The Java DTO,
value-object and shared-enum generators emitted the member symbols and nothing else, so an
adopter's repository had to restate an@intValueMapthe metadata already declares — and
the obviousordinal()compiles, round-trips within one process, and writes rows no other
port agrees with. An int-backed enum now carriesdbValue()andfromDbValue(int), which
throws on an unmapped stored value; a string-backed enum is unchanged. Found by an adopter
estate. -
Maven:
metaobjects:generateregisters its output as a compile source root. It never
did, somvn compilein an adopter module compiled none of the generated classes unless
the adopter also wired build-helper-maven-plugin. A generator's output directory is now
added as a compile root (a test root undergenerate-test-sources) when the run wrote
Java or Kotlin sources into it. Found by an adopter estate. -
A missing DB driver says where to install it.
meta migrate/verify --dbprinted
dialect 'postgres' requires 'pg'; install it: 'npm install pg', but the driver must
resolve from where@metaobjectsdev/cliis installed — in a monorepo, the package that
depends on the CLI, not necessarily the one that runs the app. The message now names that
directory and gives the command to run there, and the migration reference lists the
drivers in its install block. Found by an adopter estate. -
An invalid
.metaobjects/config.jsonnow says what is wrong, in words. A bad entry
printed Zod's raw union dump — the same issue three times, no file name, and no hint of
the accepted shape. The error now names the file, gives one line per problem with its
path (sources[0]: Expected object, received string), and for asourcesentry shows
the form that works:{ "path": "model" }. Found by an adopter estate. -
meta exportloads the libraries a project opts into. It loaded the resolved file
list alone, so underexportevery reference into an opted-in library (for example
metaobjects::iam::User) failed with "does not resolve to an object" whilegen,
verifyandmigrateloaded the same model clean. It now takes its load options from
the same helper every other command uses. Found by an adopter estate. -
meta migrate: a table rename no longer re-creates the indexes and constraints the
table already carries. With--on-ambiguous rename, the create-table half of the
drop/create pair becameALTER TABLE … RENAME TO, but itsCREATE INDEXand
ADD CONSTRAINTstatements stayed in the migration, so applying it failed with
relation "<index>" already exists. Nothing compared the renamed table against its new
declaration, so three further things went wrong with it: a constraint named after the
table (<table>_<col>_fk,<table>_<col>_chk) kept its old name and the next diff
offered to drop it; the down migration dropped the table's original indexes; and an FK on
another table that points at the renamed one was re-created, which is refused by default
as adrop-fk. The renamed table is now diffed like any other table. A constraint or index
whose only change is its name is renamed in place (RENAME CONSTRAINT,ALTER INDEX … RENAME TO) on Postgres, and the down reverses each step. The rename also runs before any
other change to that table, so a column added in the same migration no longer targets a
table that does not exist yet. On Cloudflare D1, renaming a table that the same migration must also rebuild while another table references it is now refused withD1RenamedTableRebuildError, which says to migrate the rename on its own first; emitted, it failed at apply. Found by an adopter estate renaming an entity's table;
the regenerated migration matches the one its author wrote by hand. -
Python: the
render-helpergenerator could not be pointed at your templates.
GeneratorEntry.factorytook no arguments and the registry's render-helper factory
hardcodedtemplate_root="templates", sometaobjects gen's--templates <dir>
reached the--template-specpass and nothing else. The generator was registered,
listed by--list, selectable by name — and resolved every@textRefagainst a
directory the caller had never named, which in a project whose bodies live elsewhere
is a directory that does not exist. The only way to give it a real root was the
factory-array config path, which themetaobjectsconsole script does not expose.
Fixed by porting C#'sGeneratorBuildContextrather than inventing a second shape:
every factory now takes one, andgen, the per-target config path and
verify --codegenall fill it from one resolver — because averifythat
resolved the root differently would regeneraterender-helperelsewhere and convict
the committed helpers of being stale, with a remedy that loops.--templatesis an
existing flag, so the CLI surface is unchanged. Its help text said "for
--template-spec" and now says what it actually governs. -
The default template directory is
prompts/again, everywhere it has a default
(falling back totemplates/). The Node CLI has always defaulted toprompts
(DEFAULT_PROMPTS_DIR), whiledotnet meta genandmetaobjects gendefaulted to
templates, so a project laid out the way the Node CLI documents — this repo's own
adopter estate among them — had agenlooking where its bodies were not. A
fallback, not a flip:prompts/must EXIST before it wins, so a project whose
bodies are intemplates/resolves exactly where it always did, which is what makes
the correction a PATCH rather than a move that could silently find nothing. C#
applies it ingenand inverify --codegen's regen, from one shared value, for the
same agree-or-loop reason. The Maven plugin is unchanged: it has no default and
refuses without an explicit<templateRoot>— a stricter answer to the same question. -
A
childrenentry that is not a node no longer loads — four ports, four answers,
now one (newERR_CHILD_NOT_OBJECT). Every child is one
{ "<type>.<subType>": { …node body… } }pair, so{ "field.string": "label" }has no
node in it. No port ever built one; they merely disagreed about saying so. TypeScript and
C# raised a WARNING (underERR_TOP_LEVEL_NOT_OBJECT, a code about the document root),
Java wrote an slf4j line that never reached the loader's error envelope at all, and Python
coerced the body to{}and reported nothing whatsoever for a registered type. In every
case the declared field was dropped and the load succeeded, someta genemitted the
table without its column — whilemeta verify, which loads strict (ADR-0023, #96),
refused the same file. The two commands disagreed about whether the document was valid.
It is now a hard error in every port, in lax mode as well as strict: lax tolerates an
unenforced type, which it still does, and has never meant "accept input with no node in
it". Gated byfixtures/conformance/error-child-not-object,error-attr-child-not-object
(the attr door is a separate branch in every port and a separate function in Python, where
fixing only the structural one left it answeringERR_MISSING_REQUIRED_ATTR— the
consequence, not the cause) and a per-port lax-mode test, because the corpus runs strict
and would not have caught the rule being re-gated onstrict.This refuses input that used to load, in a PATCH, under
the correction bar,
whose three-part test it meets: the form was never validly expressible (the canonical
format defines a child as that one pair); it produced no correct outcome for anyone (the
node was discarded on every port, and the ports disagreed about whether you were told);
and the repair is exactly named (the error gives the wrapper key, its JSON path, the JSON
kind found, and the two ways out — give it a node body, or remove the entry). It is not
mechanical, so there is nometa upgrade --applyfor it, which the bar permits when the
load error names the fix. -
An unregistered bare child key said the type existed.
{ "madeup": { … } }under an
entity reportedERR_MISSING_SUBTYPE— "write the fullmadeup.<subType>", advice about
a type that does not exist — in TypeScript, C# and Java. The ROOT door has resolved
registration first since FR5a, and Java pins it there with a test; the CHILD door never
got the same guard, so one rule was true at one of its two entry points in three of four
ports. Python was correct and is unchanged. NowERR_UNKNOWN_TYPEat both doors, while a
REGISTERED type that declares no default (identity,index,requirement) keeps
ERR_MISSING_SUBTYPE— that advice is correct for those. Gated by
fixtures/conformance/error-unknown-bare-child-type, with the existing
error-bare-key-type-without-baseas its control. Input that already failed now fails
with a different code and a true message; nothing that loaded stops loading. -
Java raised a null error code for a non-object root body, which surfaces as
ERR_UNKNOWN. Every other port already saidERR_TOP_LEVEL_NOT_OBJECT. Both doors in
all four ports now resolve the wrapper KEY before judging its BODY — the order that makes
{ "$comment": "prose" }an unknown TYPE rather than a body-shape complaint about prose
that was never a node. Gated byfixtures/conformance/error-root-not-object, which no
fixture covered before. -
TypeScript: a template whose name starts lowercase generated code that does not
compile. Theextractorgenerator emittedimport { extractLenient<rawName>WithLoader }
while theoutput-parsergenerator exportsextractLenient<CapitalizedName>WithLoader, so
atemplate.promptnamedshipmentRiskproduced a module importing a symbol nothing
declares —meta genexits 0 and the adopter'stscis the first thing that disagrees.
The cause was one split, not a typo:render-helper,output-parserandprompt-render
route the template name throughtemplateSymbolBase()whileextractor,output-prompt
and the api-docs model interpolated it raw, so a single model emittedrenderShipmentRisk
besiderendershipmentRiskFormatandextractshipmentRisk. Every site now uses
templateSymbolBase(), which also stopsmeta docs' API reference naming symbols codegen
does not emit. A template name that already began uppercase is byte-identical. -
Kotlin: template-tier type names are capitalized, as
renderHelperNamealways was.
responseFormatName,parserNameandextractorNameinterpolated the template name raw,
so a lowercase-initial template producedobject coachNoteParserbeside
object CoachNoteRenderHelper— one model, two conventions, and a Kotlin type named like a
variable. Java and C# capitalize all four. Found by the new compile gate below on its first
run. Generated-output change for a lowercase-initial template name only. -
The codegen-compile gate now covers the template tier, in all five ports. Its shared
model,fixtures/persistence-conformance/canonical/meta.fitness.json, declared no
template.*node at all — so the tier ADR-0056 rewrote sat outside the one gate that asks
whether emitted code BUILDS, which is how both defects above shipped with every behaviour
corpus green. The corpus now declares a respondingtemplate.promptnamedcoachNote
(deliberately lowercase-initial, deliberately carrying@responseRef) plus its three value
objects and the mustache it renders; the nodes are sourceless, socanonical/schema.postgres.sql
is untouched and no persistence scenario sees them. The TypeScript lane was
mutation-checked: reintroducing the extractor defect turns it red with the original error.
What it deliberately does not cover, and why, is recorded in
fixtures/persistence-conformance/README.md. -
TypeScript: a
field.timestampreached the wire as Postgres' own text,
"2026-09-20 12:00:00+00", not ISO 8601.docs/features/api-contract.md(and
normalization.mdbehind it) promiseYYYY-MM-DDTHH:MM:SS[.fff]Z, always UTC, and the
other four ports answer the same row that way. Codegen maps a Postgresfield.timestampto
timestamp(…, { mode: "string" }), and in string mode Drizzle returns exactly what
node-postgres received: Postgres' OUTPUT format, with a space for theTand the offset of
whatever time zone the session runs in. A strict ISO 8601 client rejects it, and under a
non-UTC session it is not even a UTC value. The comment incolumn-mapper.tsclaimed the
column "round-trips ISO-8601 strings"; it never did.@metaobjectsdev/runtime-ts's Fastify
and Hono mounts — list, get, create, update, the TPH base and subtype routes, projections
and M:N traversal — now put every timestamp column in the contract's spelling before they
send a row. The offset is converted to UTC, the fraction is truncated to milliseconds with
trailing zeros stripped, a zero fraction is omitted, and an@localTimecolumn keeps its
wall clock with noZ. Which columns count is read from the Drizzle column objects, never
guessed from a value, so a text column holding timestamp-shaped text is left alone. Under
timestampMode: "date"the same code drops the.000thatDate#toJSONpads.
No regen needed: the generated routes delegate to these mounts. A hand-written route
that sends a row itself can map it through the newly exportedtimestampWire(table)
(@metaobjectsdev/runtime-ts/drizzle-fastifyor/hono). Two limits, stated: SQLite and
D1 store afield.timestampin a plaintextcolumn, so the value goes back as it was
written; and a projection over an opaque@sqlview has no declared columns to read.
No api-contract scenario could see this: none asserts a timestamp literally, and the
runners normalizecreatedAtbefore comparing. An adopter estate's strict client found
it. It is now gated on a real Postgres whose session runs inAmerica/New_York, so
relabelling+00asZwould not pass. The test covers both adapters and both entry
points (runtime-ts/test/timestamp-wire-pg.test.ts).ci-local.sh'sts-slowlane now
runs that file andread-only-raw-sql-pg.test.ts. The second file needed a database and
had skipped everywhere. -
TypeScript (Hono): an empty
PATCHbody was a 500.@metaobjectsdev/runtime-ts/hono's
update handler passed{}straight to Drizzle's.set(), which throwsNo values to set.
The redaction path turned that into500 {"error":"database error"}. The Fastify mount has
answered an empty patch as a read (200 with the row, or 404) since the TPH discriminator
work, and Hono now does the same. The generated api-contract lane runs on Fastify, so the
corpus'spatch-empty-noopscenario never reached Hono. -
Kotlin: a generated builder could emit Kotlin that does not parse (#388). The
build()function'srequireNotNull(x) { "x is required" }could be wrapped by KotlinPoet
at a space INSIDE the string literal, and a Kotlin string has no line continuation. The
assignment is now joined with KotlinPoet's non-breaking space, so it never splits. -
Python: the render engine rendered a Pydantic model payload as an empty body. It
looked names up in mappings only, so every slot of a model payload resolved to nothing and
no error was raised. It now reads a model through its JSON-mode dump (enums render as
their values;{{#hasField}}applies), which matters because generated render helpers are
now typed with the value object's model. -
Python: two value objects with one short name wrote one module. The entity generator
named every model<name>.py, soacme::alpha::Noteandacme::beta::Note— or a value
object and an entity sharing a name — collided. A colliding value object is now
package-qualified (AcmeAlphaNote.py), andfield.object/field.mapreferences resolve
package-local instead of by the ref's bare tail. -
Python api-docs documented every model's import wrong. They said
from .<snake_name> import <Name>; the module is<Name>.py. -
Web client: every filtered request 400'd against a Spring Boot backend.
@metaobjectsdev/runtime-web'sbuildFilterQs— which every generated TanStack hook
calls — sent the filter grammar with raw brackets (filter[email][like]=…). RFC 3986
admits no[or]in a query, and Tomcat, Spring Boot's default server, enforces it:
a raw bracket gets Tomcat's own HTML 400 before the controller runs. So the generated
Java and Kotlin controllers could not serve one filtered, sorted-and-filtered or
counted-and-filtered request from the MetaObjects client, and a browser does not rescue
it — the WHATWG URL parser leaves brackets in a query unencoded too. The brackets are now
percent-encoded (filter%5Bemail%5D%5Blike%5D=…); every port's server decodes before it
parses, so the request means exactly what it did. No corpus could see this: the Java
and Kotlin generated lanes drove the controller over MockMvc, and their reference lanes
serve over the JDK's own HTTP server — none had Tomcat in the path. An adopter estate running the
generated controller in a real Spring Boot app found it. The JVM generated lanes now run
on an embedded Tomcat (TomcatHost, a real socket, the unmodified generated
controller), so this class and the raw-%class below are gated in-repo: with Tomcat's
default strict query characters, 13 of the 31 base scenarios fail. Clients you do not control can
still send raw brackets; the Java and Kotlin runtime references now name the one property
that admits them (server.tomcat.relaxed-query-chars=[,]). -
Java and Kotlin: a raw
%in a filter value — a browser's typed wildcard,
?filter[name][like]=A%— was a 500 on Java and a silently UNFILTERED 200 on Kotlin.
A%not followed by two hex digits is not a valid escape, and a browser's URL parser
sends it as written. Java'sFilterParserdecoded withjava.net.URLDecoder, which throws
on it; the exception reached the servlet as a 500 — on ANY such pair in the query, filter or
not. Kotlin's generated controller read Spring's parameter map, and Tomcat drops a
parameter holding a malformed escape from that map (Character decoding failed … has been ignored), so the filter vanished and every row came back. Both now parse the raw query
string and keep a malformed escape literal (%XXis still a byte,+still a space),
which is what the TypeScript, C# and Python servers already did. Found by the adopter estate:
11 of its 12 remaining Java contract failures. The in-repo corpus sent only%25, and the JVM
lanes ran no Tomcat, so neither could see it; pinned byFilterParserDecodingTest, a
Kotlin controller run test that hands the request over the way Tomcat does, and now the
api-contract corpus'sfilter-like-raw-percentscenario in both lanes of all five ports
(the C# runners put the raw%on the wire — .NET'sUrihad been rewriting it to%25
— and the JVM generated lanes send it verbatim to an embedded Tomcat). Kotlin
adopters with committed generated code should runmvn metaobjects:generate— the list
handlers now takeHttpServletRequestinstead of@RequestParam allParams. Java needs no
regen: the fix is in thecodegen-springruntime. -
Java and Kotlin: a TPH base's list route now filters by the discriminator.
GET /api/parties?filter[partyType][in]=CARRIER,BROKERanswered
400 invalid_filter_fieldon the JVM ports and 200 on the other three. FR-017's
design says the base allowlist includes the discriminator; the JVM generators excluded it
on the reasoning that the per-subtype route already scopes it, which missesinandne
across subtypes. Additive — a filter that was refused now works. The JVM per-subtype routes
still share the base's allowlist, so they also accept the discriminator (a no-op or an
empty list) and another subtype's columns (an empty list), where FR-017 has each subtype
reject both; the answers are truthful, and an adopter who wants the 400 owns that generator.
Adopters with committed generated code should runmvn metaobjects:generate. Gated by
the api-contracttph/scenariotph-base-filter-by-discriminator(eq,inand the bare
form). Its first run caught three test doubles that disagreed with the generated code: the C#
reference server and the Python in-memory seam ignored the filter and returned every row,
and the TS reference server's allowlist gave the discriminator no operators. -
Kotlin: no POST could create an entity whose server-generated key is
@required— and
a uuid key could not be created at all. The generated data class is also the create
@RequestBody, and a@requiredkey made it a non-null constructor parameter with no
default, so jackson-module-kotlin rejected every body that (correctly) omitted it: HTTP 400
on every create, for the shape every real model has (a sharedBaseEntitydeclaresid
required). Anincrementkey hid it — a missingLongbinds 0 — but aUUIDcannot.
Behind that sat a second defect: the create handler read a@generation: uuidkey back
from the insert, and Exposed returns only a value it inserted or an autoIncrement column,
so the key "is not in record set" on any driver that does not hand the database default
back. Now a SERVER-OWNED-on-create field — a generated key, or an@autoSetcolumn the
handler stamps — is a nullable, default-null property with no@field:NotNull(the same
relaxation a derived field on a write-through entity already had), and the create handler
mints a uuid key itself, as the TypeScript runtime does. Rows read back always carry the
key. Kotlin adopters with committed generated code should runmvn metaobjects:generate;
code that readentity.idas non-null on such an entity now needs!!or a null check.
Found by an adopter estate's Kotlin backend. -
Kotlin: a database constraint violation answered 500, not the contract's 409. The
generatedhandleConstraintViolationwas declared forRuntimeException, and Exposed raises
a violation asExposedSQLException, which extends the CHECKEDjava.sql.SQLException— so
the handler never matched and Spring answered every duplicate key and dangling reference with
a 500. It now handlesjava.sql.SQLExceptiontoo. Nothing had run the handler: it existed
only in snapshots. Runmvn metaobjects:generate. -
Kotlin: filters on an int-backed enum (
@intValueMap) returned the wrong rows with a
200. The generated filter compared every enum column as TEXT against the member symbol,
which is right for a string-backed enum; an int-backed column stores the declared integer,
sofilter[status][eq]=DELIVEREDcompared'30'to'DELIVERED'—eqandinmatched
nothing andnematched everything. The filter now compares through the enum-typed column,
whose codec writes the declared integer; a symbol naming no member matches no row. Run
mvn metaobjects:generate. -
Kotlin docs: the documented install block could not build a running app. The generated
tables importexposed-java-timeandexposed-json, andDatabase.connect(...)needs
exposed-jdbcat runtime;docs/ports/kotlin.mdlisted onlyexposed-core. Its generator
table now lists all three, and says the controller needsKotlinRelationsGeneratorwhenever
the model has a M:N relationship (the traversal routes call helpers only that generator
emits). AKotlinSpringConfigGeneratorrun withvalidatorEnabled=falseno longer emits a
KDoc claiming the validator runs. -
Python runtime: a
field.currencyread from a view answered as a JSON STRING, not
integer minor units. A table's currency column is BIGINT, which the driver already hands
back as anint. A view's need not be:SUMover BIGINT is Postgresnumeric, read back
as aDecimal, and a FastAPI route serializes aDecimalas a string — so
GET /api/shipment_summariesanswered"totalRate": "100000"where the contract says
100000, while the entity routes beside it were right. The value's wire form depended on
the physical column type instead of the declared field.ObjectManager's read codec now
decodes an integral currency value tointwhatever the column type; a fractional one (an
avgover minor units) has no honest integer and is returned as read. Found by an adopter
estate's Python port once its read-only projection router existed (1.0.5's F22 fix). -
Python codegen: the generated TPH repository Protocol now states what the base
collection must return. The router returns whatever the consumer's repository returns,
and the natural repository —ObjectManager— reads a TPH base with the base's columns
only (the persistence contract in every port). An adopter wiring one to the other served
GET /api/<base>with every subtype field missing and nothing erroring. The Protocol's
docstring now says that forsubtype=Noneeach row carries its OWN subtype's fields, and
how to get them. Generated output changes (a docstring only) — runmetaobjects gen
to pick it up. -
Maven plugin: a relative
<sourceDir>resolved against the shell's working directory,
not the module. The documented<sourceDir>src/main/metaobjects</sourceDir>therefore
failedSourceDir [src/main/metaobjects] does not existin any multi-module reactor build
run from the parent (Maven keeps the root as the working directory for every module) and
undermvn -f <module>/pom.xml. It now resolves against${project.basedir}, as every
relative path in a pom does; an absolute<sourceDir>is unchanged. The plugin's own test
had pinned the old behavior in a comment ("paths here are ABSOLUTE on purpose") instead of
fixing it. Nothing to do on upgrade. -
TypeScript: two
@metaobjectsdev/runtime-tswritable mounts answeredPUTwith a 404.
The cross-port REST contract (FR-008) makes the update verb reachable via BOTHPATCH
andPUT, routed to one handler, and every other port's controller maps both. The
Drizzle Fastify mount already registered both by default; the Hono mount and the
ObjectManager Fastify mount registeredPATCHalone unlessupdateMethodsaid
otherwise, so aPUTthe contract promises fell through to the framework's own 404.
Neither api-contract lane runs those mounts, which is how it survived — the same blind
spot the Hono error-code defect sat in. Both now mount both verbs by default. Additive,
nothing to do on upgrade:updateMethod: "patch"or"put"still restricts the update
verb to exactly the one it names, and now says so in its doc comment rather than claiming
a"patch"default that the Drizzle mount had not honoured for some time. One case is
not silent: a consumer that registered its OWNPUThandler at<path>/:idbeside the
ObjectManager Fastify mount now gets Fastify's duplicate-route error at startup — pass
updateMethod: "patch"to keep that handler. -
API docs: TypeScript, Java and Kotlin did not document
PUTon a writable entity. The
generated update route servesPATCHandPUTfrom one handler in all five ports; C#
and Python documented both, the other three documentedPATCHonly, so an adopter (or
an agent readingmeta docs --agent) was told an address the server answers did not
exist. Each builder now emits aPUTrow besidePATCH, and the accuracy tests that
check every documented verb against the emitted routes count it. Generated docs gain
one REST row per writable entity; nothing else in the output moves. -
Java: a database constraint violation answered a bare 500 instead of the cross-port
409 {"error":"constraint_violation","constraint":"unique"|"foreign_key"}. The generated
CRUD routes had no try/catch around their writes at all, so a driver failure reached Spring's
default handler. A client-supplied foreign key that does not exist, or a value duplicating a
unique one, is a CLIENT error — and theidentity.reference/identity.secondarythat
declare those constraints are the same metadata the route already uses to reject bad enum
members and missing required fields. It just never translated the constraints the DATABASE
enforces. Worse, Spring's default error body can carry the driver message, which names the SQL
and sometimes the bound values: on a POST whose columns hold PII that is user data reflected
back out of generated code the adopter never wrote.New
com.metaobjects.generator.spring.runtime.ConstraintErrors, wired into all six write
handlers (create / update / delete, vanilla and TPH). It is the Java port of runtime-ts's
constraint-errors.tsand C#'sConstraintErrors— the same algorithm, arm for arm, not a
third design: walk the exception chain, match driver CODES first with the constraint-name
vocabulary as fallback, 409 for referential/uniqueness conflicts with existing state and 400
for a value the request itself got wrong. An unrecognised throwable is RETHROWN, so the
operator keeps the full diagnostic and the caller gets a plain 500 carrying none of it.Walking the chain is the whole trick: Spring wraps every JDBC failure in a
DataAccessExceptionwhose own message names no constraint, so a top-level-only read
classifies nothing — the same defect the TypeScript version hit under Drizzle's wrapper and
C#'s under EF'sDbUpdateException.SQLException#getNextException()is walked too, because a
batch failure puts the useful exception there rather than ongetCause(). No driver
dependency:getSQLState()is JDK API, so Postgres's SQLSTATE class 23 is read directly and
message text covers the drivers that carry no code.Python gains the same mapping:
metaobjects.codegen.runtime.constraint_errors, wired into
every generated write handler (create / update / delete, vanilla and TPH). Its chain walk
covers__cause__,__context__AND.orig— SQLAlchemy hands the DBAPI exception on the
last of those rather than either dunder, so a walk of only the standard chain would classify
nothing under the most common Python persistence layer. SQLSTATE is read from whichever of
sqlstate/pgcode/pgerror/code/errnothe driver populates, by name, so the
module imports no driver.Kotlin takes a different mechanism to the same wire result, and the difference is a
correctness point rather than a stylistic one: an@ExceptionHandleron the generated
controller instead of a try/catch per verb. An Exposedtransaction { }whose statement failed
must be allowed to ROLL BACK — catching inside the lambda and returning a value would have
Exposed commit an already-aborted transaction, which Postgres rejects outright. Letting the
exception leave the transaction block and answering from the handler rolls back first. It also
covers every write verb at once, including any added later, where a per-handler wrapper is six
edits a seventh handler silently misses. The classifier is emitted INLINE rather than imported,
because a generated Kotlin controller carries no compile-time MetaObjects dependency — the same
reason that port emits its filter parser inline.All four non-TS ports now answer the same
409/400for the same input. Found by building a
Java adopter app. -
Java: no POST could create anything for an entity whose primary key is
@required. The
generated Spring controller's vanilla create handler validated the WHOLE request DTO:
if (!validator.validate(dto).isEmpty()) return 400 {"error":"validation"}. But
SpringDtoGeneratoremits ONE record used as both request and response body, so a@required
primary key earns@NotNull— a true statement about the RESPONSE and a false one about the
request, sinceidentity.primary @generation: uuid|incrementmeans the database supplies the
key and the caller must not invent one. Every create therefore answered 400, and the only
workaround was a client-supplied primary key, which defeats the declared generation strategy.The generator already knew the rule and one door never called it:
SpringDtoGenerator.settableFields(...)returns "effective scalars MINUS the primary key MINUS
the discriminator", and its javadoc states it outright — "the PK is auto-generated and the
discriminator is injected from the URL, so neither is validated from the body." It was applied
on the TPH create path and to<Entity>Patch. So a TPH entity POSTed fine while a plain one
did not: same model, same generator, two write paths, one of which had learned the rule.The vanilla create now emits a
SERVER_OWNED_ON_CREATEset (primary-key components plus
@autoSetcolumns) and filters violations by top-level property name.validator.validate(dto)
is KEPT rather than narrowed to per-propertyvalidateValue, because it is what cascades
@Validinto nested value objects — the violations are filtered, not the validation weakened,
and a nested path likelabels[0].codecan never match a top-level name, so cascaded
constraints still reject. An entity with no server-owned component keeps its previous handler
byte-for-byte.No corpus could have caught it, which is worth recording: the api-contract corpus's only
entity declaresAuthor.idwith no@required, so no port's DTO ever annotated the key and
both lanes stayed green everywhere. Found by building a Java adopter app, where a shared
BaseEntitydeclaresidrequired — the shape real models actually have. Now gated: the
jsonb/sub-corpus'sDocument.idis@required, so its POSTs without a key run through
both lanes of all five ports — and on its first run it found the same defect in the
TypeScript runtime (next entry). -
TypeScript runtime:
ObjectManager.createrefused a record that omitted a@required,
server-generated primary key.resolveIdentityknew an@generation: incrementkey is the
store's to fill — it resolves it as driver-generated — but the required check that followed
ran over the merged row, found noid, and threwValidationError (id: required). So every
create throughObjectManager, including the ObjectManager-driven Fastify adapter
(@metaobjectsdev/runtime-ts/fastify), answered an entity whoseBaseEntitydeclaresid
required with a 400: the Java defect above, in a second port. A driver-generated key is now
exempt from required-on-insert when ABSENT, exactly like a@defaultcolumn; an explicit
nullis still a required failure.createManyfollows the same rule. The generated
Drizzle/Zod routes were never affected — their insert schema already omits a generated key. -
Java/Kotlin: a
<loader><libraries>entry naming a LAYER (iam/db) failed the build,
so the JVM could opt into a library's design but never its tables. Libraries are
layered and the selection is layer-granular —"iam"is the inert core,"iam/db"the
separate opt-in carrying thesource.rdbdeclarations. The Maven plugin validated a
pom's tokens againstLibrarySources.knownPackages(), which answers with the library
NAMES ([ai, iam]), instead ofknownTokens(), which answers with the set a selection
is actually drawn from ([ai, ai/db, iam, iam/db]). So the same selection the
TypeScript and Python configs carry failed outright withnames unknown package(s) [iam/db]; available: [ai, iam]— a list naming nothing the user could type instead.The resolver was never involved:
librarySources()splits a token and looks the layer
up, and has since layers landed. Only the mojo's pre-validation refused it, and
knownTokens()— added by the same change that introduced layers, with a javadoc reading
"what a config error prints" — had no caller anywhere in the port. No JVM test
asserted a library DB layer loads at all, which is why one unwired door could ship.Found by building a Java adopter against the published plugin. Consequence while it was
broken: a core layer declares nosource.rdb, so a JVM project could take a library's
design but not its schema, and generated from the same model as another port would
legitimately disagree with it. -
C#: a
field.enumthatextendsanother entity's enum field generated code that does
not compile.extendson afield.enumhas two very different readings and only one
materializes a type: FR-019's shared enum is a ROOT-level ABSTRACTfield.enum, while a
field extending another ENTITY's concrete field — the ordinary way a projection restates a
column it reads,ShipmentSummary.status extends "Shipment.status"— shares nothing. The
C# type-name rule keyed on "has a super" rather than on the shared-declaration predicate,
so the second case took the super's bare simple name and the entity nested
public enum Statusbeside its ownpublic Status Status { get; set; }— CS0102, a
member and a nested type with one name.meta genexits 0; the adopter's build is the
first thing that disagrees. The name now comes from the SAME predicate that decides
materialization, so a reference always resolves to a type that exists. Only the
non-shared case changes spelling (to the entity-qualified<Entity><Field>); shared and
@providedenums are byte-identical. -
C#: a read-only projection's generated routes named a filter allowlist nothing emitted.
RoutesGeneratoremits theFilterParser.Parse(qs, <Cls>FilterAllowlist.Fields, …)line
for a projection's list route like any other read, butFilterAllowlistGeneratorskipped
read-only projections — on a rationale, written into its own comment, that "projections
are not filterable in the routes generator today", which had stopped being true. Skipping
the allowlist did not disable filtering; it left a dangling reference and the emitted tree
did not compile. The two generators now share one predicate, and a test asserts the
invariant directly: every<Cls>FilterAllowlista routes file REFERENCES is emitted. A
projection with no@filterablefield gets an empty allowlist, exactly as an entity does,
and the api-docs builder now documents the projection's Filter symbol. -
C#: a TPH base-path collection dropped every subtype field.
GET /<base>returns
List<Base>, and System.Text.Json serializes a collection by its DECLARED element type —
so a discriminated-union list answered 200 with rows quietly missing half their columns,
with nothing to error on. A single-object read was unaffected, because ASP.NET falls back
to the runtime type when it differs from the declared one, which is exactly why this
survived:GET /parties/{id}looked right whileGET /partiesdid not. The base class now
carries a[JsonDerivedType(typeof(<Sub>))]per concrete subtype. No$typekey is
added — the model already carries@discriminator, emitted as an ordinary property, and a
second C#-only key would be an addition to a cross-port wire contract. Non-TPH entities are
byte-identical. -
C#:
field.timestampcrossed the wire as+00:00rather than the documentedZ.
docs/features/api-contract.mdspells a timestamp ISO 8601 with the UTC designator;
System.Text.Json's default forDateTimeOffsetrenders UTC as a numeric zero offset. Both
are valid ISO 8601, which is why it went unnoticed — but the cross-port gate is a BYTE
comparison of the sametimestamptzrow read by five ports, and two spellings are two
answers.MetaObjects.Codegen.Runtime.Iso8601TimestampConverterwrites UTC withZ
(fractional seconds only when the instant has them, so a whole-second value round-trips to
the string it arrived as) and reads any ISO 8601 spelling, treating an offset-less value as
UTC rather than as server local time. Register it onConfigureHttpJsonOptionsbeside
JsonStringEnumConverter; the generated handlers deserialize with the app's CONFIGURED
options precisely so a host registration reaches them. A near-copy previously existed only
inside the integration-test project, where an adopter never saw it — and that copy did not
produce the documented spelling either (DateTimeOffset.ToString("o")keeps the numeric
offset at UTC; reachingZneeds theUtcDateTime). -
C#: a constraint violation answered 500 instead of 409. A generated CRUD route had no
try/catch aroundSaveChangesAsync, so a client-supplied foreign key naming no row, or a
value duplicating a unique one, reached ASP.NET's default handler and came back as a bare
500. Both are CLIENT errors, declared by the same metadata (identity.reference,
index.unique) the handler already uses to reject bad enum members and missing required
fields. The contract — and every other port — answers409 {"error": "constraint_violation", "constraint": "foreign_key" | "unique"}, withcheckand
not_nullas 400.MetaObjects.Codegen.Runtime.ConstraintErrorsis the C# half of
runtime-ts'sconstraint-errors.ts, algorithm for algorithm: it walks the exception chain
(EF wraps every provider failure in aDbUpdateExceptionwhose own message names no
constraint, so a top-level-only read classifies nothing) and matches driver CODES, so it
binds to no specific ADO.NET provider. An unrecognised failure still rethrows and is still
a 500. -
C#:
dotnet meta genkeyed its hash manifest by the OUT DIR, so a second--outunder
one project could destroy a hand edit in the first..gen-state/.hashes.jsonis ANCHORED
on the project — the gen-state directory is derived from it and cannot be configured per
out dir — but C# alone still wrote out-dir-relative keys (AppDbContext.g.cs), while
TypeScript and Python write project-root-relative ones (ports/ts/src/generated/Party.ts).
Two ports generated from one model therefore collided: a file emitted under both--out
paths shared a single entry, and whichever ran second recorded ITS content there — after
which the first out dir's hand-edited file hashed as pristine and the nextgenoverwrote
it. That is the exact failure the manifest exists to prevent, reached by runninggenin a
different directory. The C# source has carried a note describing this as latent and unfixed
since Python closed the same hole; an adopter estate generating a TypeScript and a C# port
side by side is where it stopped being latent.Keys are now project-root-relative in every port that keeps a manifest (Java keeps none).
No adopter action is required and nothing refuses on upgrade: the out-dir-relative
spelling is still READ as a legacy key, so an existing manifest keeps working, and each
file drops its legacy entry as it is regenerated — the manifest converges on one spelling
per file rather than carrying both. A programmatic caller that names no project keeps the
out-dir-relative key. Both sides of the relative-path computation are normalised and NOT
link-resolved, so they stay symmetric; resolving one side only would walk every key out of
the project under a directory symlink, and a drift gate would then report "in sync" over
stale output. -
TypeScript: a PATCH that strips to nothing answered 500. A generated TPH subtype route
strips the discriminator from an update body, because a row's subtype is immutable — so a
body naming ONLY the subtype (the one key a client is most likely to send at such a route)
stripped to{}, reached Drizzle's.set({}), and threwNo values to set. The throw was
caught, andclassifyConstraintErrorcorrectly declined it — an empty update is a
programming error, not a constraint violation — so it took the redact path and answered
500database error. The fix is NOT a wider catch: an empty patch must never reach the
driver. It now answers as a READ does, including the 404, so a no-op patch cannot become a
hole through which one subtype's route reports another subtype's row. This also removes a
divergence INSIDE the TypeScript port: the reference server, which updates through
ObjectManager, already no-opped and returned the row, so the two api-contract lanes
disagreed on this input.This fix deliberately did not settle the cross-port contract; the entry below now does.
-
An empty PATCH answered five different ways across the ports; it is now one, and gated.
FR-037 PATCH-5 has always said a patch that strips to zero caller assignments reads the
current row back — but nothing in the corpus asserted it, and an unasserted corner is where
divergence lives. The ports had drifted to: TypeScript 500 on the generated mount and 200 on
the reference lane (fixed above), C# and Java 400validationfrom their base
AuthorApiServerwhileTphReferenceServer/JsonbReferenceServerin the same directory
200'd, Kotlin 200, Python 200. Both 400s cited "per FR-035" — a rule that says the opposite.
The answer is now 200 with the current row in all five ports and both lanes,404when
the row does not exist, pinned by the new
fixtures/api-contract-conformance/scenarios/patch-empty-noop.yaml.The fix in C# and Java is the removal of an empty-
SETguard: the@autoSet:onUpdate
stamp was already being appended unconditionally right after it, so dropping the guard makes
theSETnon-empty on its own and the ordinary write path answers correctly. That also
settles how PATCH-3 and PATCH-5 meet — the server-side stamp IS an assignment, so on an
entity carrying one the literal zero-assignment read-back path is never reached, and
patch-empty-noop.yamlasserts the stamp still bumps.docs/features/generated-mutations.md
PATCH-5 is corrected accordingly; it previously read "no write is executed", which holds only
for an entity with no onUpdate column.No adopter action: a request that used to 400 or 500 now succeeds.
-
Java: a field name that is legal metadata could not be a record component. The JLS
forbids a record component named after a no-argumentObjectmethod
(clone/finalize/getClass/hashCode/notify/notifyAll/toString/wait) — its
accessor would have to override a final method. The shared fitness corpus has declared
object.value Settings { field.boolean notify }all along; nothing had ever COMPILED a
record emitter's output over it. The name is now escaped with a trailing underscore and
pinned back with@JsonProperty, so the Java identifier changes and the wire does not.
The escape reaches every place the name becomes a Java METHOD, not only the component that
declares it: the accessor CALL sites in the@autoSetstamping helpers, the<Entity>Patch
(a plain class, where aString-typedtoString()COMPILED and silently overrode
Object.toString()), the abstract-shape interface, and — at run time — the property-name
string the generated PATCH handler passes toValidator#validateValue, which resolves a
record's property by its component and threwIllegalArgumentExceptionon the declared
name. This is a Java restriction and it stops at Java:notifystays a legal field name in
all five ports, and no other port and no payload changes. It changes generated Java
output: a generated Spring controller gains one import and wraps its PATCH loop's property
lookup, whether or not the entity has a reserved name, so Java adopters with committed
generated code must re-run codegen — for almost all of them that is the whole diff. The
record/DTO tier changes only for an entity that actually declares such a field. -
Java: a field named after a Java keyword generated code that could not compile, on both
emitters. A keyword is a different restriction from theObject-method names above — it
is illegal as ANY identifier, so it is a parse error rather than an override problem, and
each emitter hit it differently. Theentitygenerator prefixes its accessors
(getX/isX/setX), which shields the method NAME but not the setter's parameter, so a
field namedclassemittedsetClass(String class), plus agetClass()that is final on
Object. The record emitters have no prefix at all — the field name IS the identifier — so
the same field emitted a record component declaredString class. Both are now escaped with
the same trailing underscore, inert for every name that does not need it, and the wire name
is pinned with@JsonPropertyexactly as for theObject-method names. The two emitters
now share one reserved-name set (JavaIdentifiers): they previously answered the question
separately, which is how fixing one left the other still generating uncompilable code. Not
reachable from the codegen-compile gate — a shared corpus cannot carry every illegal name in
every target language — so each emitter carries its own fixture. -
Java: per-object
.javawas written unguarded, and was then mistaken for hand-written
work.MultiFileDirectGeneratorBasestreamed straight to aFileOutputStream, which
cannot be guarded at all — opening the stream truncates the file, so by the time there is
content to compare the user's work is already gone. It also wrote noGENERATEDmarker, so
a sibling generator that DOES go through the guard read the output as somebody's own and
declined.own-your-codegen.mdpromised the opposite on this port for the bulk of its
output. Now buffered and routed throughGeneratedFileWriter, with the marker leading the
header — that order is not incidental: guarding an emitter that does not write the marker
makes run 1 write and every run after refuse, which is the silent staleness the guard exists
to prevent. -
Java: two generators could claim one output path and one silently won. TypeScript's
runGenand Python'srun_genboth refuse a conflicting duplicate path; Java has no runner,
so nothing was in a position to notice. This was live:entityandvalue-objectboth emit
a Java type for anobject.valueat the same path — a POJO class and a record, not variants
of one thing — so the selection's output depended on generator order and the DTO tier could
end up bound to a type carrying none of the jakarta constraints it was built to validate.
The only signal was a WARN. The check now raises, with the TypeScript rule (byte-identical
re-emission is one file; differing content is an error naming both generators). Ownership
still wins over it: the marker question is asked first, so two generators colliding on a
path the adopter hand-owns produce two warnings and a green build rather than a failed
reactor — neither write could reach disk, so there is no order-dependent output to report. -
Kotlin: a package containing a
field.inetcolumn emitted a support file that never
type-checked.MetaInetUriColumnType.kt'ssetParameterassigned
if (value is InetAddress) value.hostAddress else value— anAny?— intostmt[index],
which takes a non-nullAny. Every such package emitted a file that cannot compile, and the
null path it was reaching for was the one thing it could not express. A non-InetAddress
value (a null for a nullable column above all) now delegates tosuper.setParameter, which
knows how to bind it. Nothing caught it because the existing text-matching test passes on a
file that cannot compile. -
meta migratedropped every foreign key onto a TPH subtype, andmeta verify --dbcould
not see it. A subtype has no table of its own, so the expected-schema builder skipped it
before registering it as an FK target; anidentity.referenceontoCarrier(a subtype of
Party) resolved to nothing and the constraint was silently left out of the migration.
verify --dbdiffs against the same builder, so expected and actual agreed and it reported
the schema in sync. The FK now references the discriminator base's table. The same fold lost
two more things on the base table itself: the FK of a reference declared on a subtype
(its column was folded, its constraint was not), and every field and reference declared on
an abstract level between the base and a concrete subtype (Party→ abstract
Organization→Carrier), which the Drizzle schema did declare. An adopter with such a
model will seeadd-fk(and possiblyadd-column) changes in the nextmeta migrate
diff — those are the constraints and columns the metadata always declared. -
A TPH subtype-only column carried a DB
DEFAULTit must not have. The expected-schema
TPH fold forces such a column nullable — sibling-subtype rows must stay NULL in it — but
kept the authored@default, while the Drizzle schema'sforceNullablecontract also
suppresses the default. Someta migrateemitted a DDL default the app's Drizzle schema
knows nothing about, and a sibling subtype's INSERT, which omits the column, silently took
the default instead of NULL — defeating the fold's own intent.verify --dbcould not see
either side, because migrate and verify share this builder. The subtype-only column now
carries no default; the base's OWN column keeps its default, since every row has it.
Migration output changes for a TPH adopter with a@defaulton a subtype-declared
field: the nextmeta migratediff drops that default. -
A
@cardinality: onerelationship declared on a TPH subtype (or an abstract mid level)
never reached arelations()block. The relation map filed it under the declaring
entity's name, but a TPH subtype has no module of its own — the block renders on the
discriminator base's — so the entry sat under a key nothing reads and the belongs-to
navigation was silently never emitted. Cardinality-one entries, including a junction's
one()sides, are now keyed throughtphStorageName, the seam the target side already
used; M:N entries stay keyed per declaring entity, because the routes tier mounts an M:N
under EACH concrete subtype's segment. A name two subtypes claim with different targets is
reported through the gen warnings channel rather than silently resolving to one target.
The independent oracle gained a relations-tier rule — restated from its own storing-object
walk, not from codegen's map — whose absence is exactly why this defect had no independent
check. Generated output changes: re-runmeta gen; a TPH base's relations block gains
the subtype-declared navigations.meta docs/api-model output changes with the relations
tier: for a CONCRETE discriminator base with a writable source, the subtype's unit loses
its<Sub>Relationssymbol — it documented an export that never existed, since a subtype
emits a value-object module with no relations block — and the base's relations symbol
gains the folded subtype-declared navigations, which are real. For an ABSTRACT
discriminator base, or one with no writable source, no unit in the hierarchy carries a
relations symbol at all, because none was ever real there: both shapes route to the
value-object emission, so no module in the hierarchy renders a block — and a plain
sourceless entity carrying a belongs-to loses the same long-standing phantom symbol. An
abstract level with no concrete descendant beneath it likewise files nothing —
its FK column never folds into the base's table, so an entry would have named a column the
table does not have, generated code failing tsc whilemeta genexited 0 — and its unit
loses the same phantom symbol. -
A reference or M:N relationship onto a TPH subtype generated code that did not compile.
The subtype's module exports no table const, but the FK's.references(), therelations()
entry and the M:N traversal all importedcarriersfromCarrier.ts(TS2724). They now
bind the discriminator base's table (partiesfromParty.ts). An M:N onto a subtype also
filters the related rows to that subtype: the junction FK can only point at the base
table, so it can hold a sibling subtype's id, and the traversal used to return that row too.
mountM2mRoutetakes a new optionaltargetDiscriminator: { column, value }, which the
generated mount passes; omitting it keeps the old behaviour. The generated Drizzle table of
a discriminator base also gains the.references()of subtype-declared references, matching
the migration. New helperstphStorageObject/tphStorageNameanswer "which object's
table stores these rows" for owned generators. Generated output changes: re-run
meta gen. The Kotlin port carried the same defect class — a reference onto a
subtype emitted.references(<Sub>Table.pk)naming a table that is never emitted —
closed with the traversal work below through the same storage-object redirect
(KotlinTphPlan.storageObjectOf). -
M:N traversal routes inside a TPH hierarchy were never mounted at all. The TPH emit
path (renderTphRoutesFile) rendered the polymorphic mount and the full per-subtype CRUD
set but never consulted the relation map, so every many-to-many navigation in a hierarchy
vanished from the generated API — a relationship declared on the base as much as one
declared on a subtype. Nothing failed: a route that is never mounted is an ABSENCE rather
than a compile error, which is why the codegen-compile gate stayed green while the endpoint
404'd. The base's relationships now mount at the base path — every row of the shared table
is a legitimate source, so that mount is byte-identical to a vanilla entity's — and every
relationship a subtype RESOLVES, declared on it or inherited (including one declared on an
abstract mid level, which otherwise serves nowhere), also mounts under the subtype's
segment. The overlap is deliberate: only the subtype segment gates the source id.
mountM2mRoutetakes a new optionalsourceDiscriminator: { table, pkColumn, column, value }— a stage 0 that reads the source row's own discriminator from the shared base
table and answers 200 with[]when it is not this subtype's: the id names no row of
THIS subtype, so it has no relations. Absent, the behaviour is unchanged. An M:N whose
junction cannot be paired — the loader never checks subject pairing, so the model loads
clean — now emits a WARNING through the gen warnings channel naming the entity, the
relationship, the junction and the derivation's own reason, instead of silently producing
no route; the build still passes. The feature-combination ledger'stph-m2m-routesentry
is drained, and its m2m check now runs for hierarchy holders too, reading the traversal
back through every segment the oracle says serves it, sibling-subtype source ids included.
Scope is now all five ports: those same two gaps (the controller generator
skipping TPH subtypes at the top of its entity loop, and the TPH emit path asking no
M:N question at all) closed in Java and Kotlin last, completing the agreed five-port
pass. C# reaches the same contract by its own route: the subtype-scoped mount
proves ownership withdb.<Base>.OfType<Sub>().AnyAsync(...)before touching the
junction; an M:N whose TARGET is a TPH subtype binds to the base'sDbSetnarrowed by
OfType<Sub>(), because a subtype has noDbSetof its own and the emitted routes
file previously did not compile; a TPH subtype-only scalar carrying both@required
and@defaultnow applies that default instead of starting at the CLR default; and a
subtype separated from its base by an abstract mid level resolves its base class to
the nearest ancestor that is actually emitted, rather than naming a class that
EmitAbstractShapes: falsenever emits. Python reaches it by its own route too: it
ships no runtime SQL layer (the repositoryProtocolis consumer-implemented), so the
subtype-scoped mount composes the seam's ownfind_by_id(subtype, id)— the exact
lookup the per-subtype GET route already calls — as the subtype-ownership gate
answering 200 with[]for a sibling's id, rather than asourceDiscriminator
stage 0; and an M:N whose TARGET is a TPH subtype WIDENS the
find_related_<relation>seam with atarget_subtypeparameter carrying the
resolved@discriminatorValue— threaded per relation NAME across every mount of
that name in a hierarchy (str | Nonewith an explicitNoneat non-TPH mounts,
because a subtype may legally shadow a base-declared relationship with a
differently-TPH target) — rather than adding a WHERE clause to a shared Drizzle
helper. One Python behaviour transition to know: a base-declared M:N whose target is
one of the hierarchy's OWN subtypes now FAILSmeta gen(M2MDerivationErrorfrom
the per-subtype resolution tripping the shared self-join heuristic) where it
previously generated green with the route silently absent — a green-to-red
transition for existing metadata that the "gains routes" sentence above does not
convey — and the raised message mislabels that shape as an ambiguous self-join
(advising@sourceRefField/@symmetric) when the author actually wrote a hetero
base-to-subtype relation; repairing that heuristic is out of scope here.
Java reaches it by its own route too: the TPH repository seam gains a whole-table
finder per base-declared relationship plus, for every concrete subtype that
resolves the relation (its own or inherited), a subtype-scoped finder under a
DISTINCT name (findTagsForBridge) — Java cannot declare two methods with the same
erasure that behave differently by discriminator — and the GENERATED controller
enforces the subtype gate itself: it composes the seam's own
findByIdAndType(id, "<disc>")(the lookup the per-subtype GET already gates on)
and answers an empty list, HTTP 200, for a sibling's id before the traversal finder
ever runs. Leaving that gate as a javadoc obligation on the finder — directly
beside the unscoped finder a copy-paste body would clone — compiled, type-checked,
and silently 200'd a sibling's rows; enforced in generated code it costs one
redundant primary-key lookup and adds no new persistence assumption. Kotlin reaches
it by its own route too, and carries the physical half the consumer-owned port
never owed: a TPH subtype has NO ExposedTableobject and NO data class of its
own — its rows live in, and are shaped like, the discriminator base's — so a
reference onto a subtype, a reference declared on one, and an M:N whose target is
one now resolve through a newKotlinTphPlan.storageObjectOfto the base's table
instead of naming aBridgeAuthTablethat is never emitted (which was an
unresolved-reference compile failure, not merely an absent route); an M:N whose
target is a CONCRETE subtype ANDs the target's discriminator into the emitted join,
narrowing the shared table's rows back to the declared target's (an abstract
mid-level target has no single discriminator value and stays unfiltered); the
hierarchy base's relations file gains the query helpers for the M:N relationships
its subtypes declare on themselves, and the controller's per-subtype mounts reuse
those SAME helpers behind a stage-0 discriminator check that answers[]for a
sibling's id.
Generated output changes: a TPH hierarchy with M:N relationships gains routes
on the nextmeta gen, in all five ports — and a Java adopter implementing a TPH
repository interface gains finder methods to implement. -
An abstract object that inherits a source got a queries and a routes file that did not
compile. An abstract level has no table of its own, only a type-only shape, yet the
queries and routes generators gated on "has a source" alone, soOrganization.queries.ts
imported anorganizationstable that does not exist. They now gate onservesReadApi,
the predicate the TanStack hooks already used, so routes, queries and hooks exist for the
same objects again; the API docs follow. If you ejectedqueries.ts,routes.tsor
routes-hono.ts, changehasAnyRdbSource(e)toservesReadApi(e)in itsfilter(both
are exported from@metaobjectsdev/codegen-ts) or re-eject, then delete the stale
<Abstract>.queries.ts/.routes.tsfiles the old generator left behind. -
loadMemory(repoRoot)did not load the libraries a project opts into. When called
with nofiles, it took the files, file ids and dependency imports from
.metaobjects/config.jsonthroughresolveCollection, but notlibraries, which FR-043
moved into the same file. A project opting intoiamloaded throughmetaand failed
through the SDK: every reference into the library wasERR_INVALID_REFERENCE, and the
library-package guard never ran. It now takes the selection from the collection too;
an explicitlibrariesoption still wins. -
Three more reference shapes generated code that did not compile, found by the new
feature-combination gate (below). An M:N self-join's route mount imported the source
table a second time beside the entity-module import (TS2300, and aSyntaxErrorwhen
Node loads the file) — every directed and symmetric self-join. A cardinality-one
relationship onto the declaring entity itself (Order.parent), or from a TPH base onto
one of its subtypes, imported the entity's own table into its own module (TS2440). And a
reference onto an abstract level of a TPH hierarchy imported a table const that module
does not have, whilemeta migratedropped its FK: the rows live in the discriminator
base's table, and both now bind there. Generated output changes for all three. -
A TPH subtype's declared type and its read schema disagreed, so
parse<Base>()did not
compile. The generated<Sub>interface and the<Sub>Schemathe polymorphic
dispatcher parses through were emitted by two rules that answered the same question
differently. The schema made the PRIMARY KEY.nullable()— it is the shared base table's
key and is present on every row — while the interface did not admitnullfor a
subtype-only column, which genuinely is NULL on a sibling's row. Either way the value
parse<Base>()returns was not assignable to the base union (TS2322). Both emitters now
route through one predicate,isTphReadNullTolerant. Generated output changes: a TPH
subtype's interface widens its null-tolerant fields to| null, and its read schema stops
widening the PK. -
A TPH subtype's read schema consulted
@default, so a required column carrying one did
not compile again. The one predicate above askedfieldWillBeOptional(not-required OR
has-a-default) on the read path too, widening aNOT NULL DEFAULTcolumn to| nulland —
through the.optional()that predicate mirrors — to| undefined, while the declared
interface keyed its?on bare@requiredand saidT | null: the valueparse<Base>()
returns was not assignable to the base union (TS2322). A default decides what an INSERT
may omit; it says nothing about what a READ can see, so the read shape now answers from the
physical column: a subtype-only column is nullable (the Drizzle fold drops.notNull()for
exactly that set), a base-declared column is nullable iff not@required, and the primary
key is present on every row. Nothing with a column is.optional()— a selected row
carries every column as a key — and a derived (origin.*-bearing) field is the one member
with no column, so it alone keeps.optional(). The insert and update shapes are
unchanged: consulting@defaultis correct there. Generated output changes: a TPH
subtype's read schema (re-runmeta gen); the feature-combination ledger's
tph-required-default-read-typeentry is drained as a result. -
@dbColumnType: jsonbemitted a SQLite column that did not match its own type. The
open-JSON-bag escape hatch types the fieldunknownin Zod and TS, because the value
round-trips as a PARSED JSON value on every dialect. On SQLite the attribute fell through
to a plaintext()column typedstring, so the generated queries did not compile — and
had they compiled, the value would have come back as a JSON-encoded string. It now emits
text(..., { mode: "json" }), the same idiomatic formfield.objectandfield.map
already take on that dialect. Postgres is unchanged. Generated output changes on SQLite. -
A view-backed read model carrying an int-backed
field.enumgenerated a module that did
not compile. A@intValueMapenum persists as an integer column, and the entity table
emits a module-local DrizzlecustomTypecodec to encode and decode it. The view path
emitted the CALL SITE and let the codec's name fall through to the drizzle core import, so
the file opened withimport { statusIntEnum } from "drizzle-orm/sqlite-core"— a member
that package does not export.meta genexited 0 and only a typecheck caught it
(TS2305). The codec emitter is now shared (templates/enum-int-codec.ts) and the view
declares the codecs it references, sorted by const name so output stays deterministic.
This covered BOTH view hosts — anobject.projectionand a write-through entity's replica
view render through the same function, and the read-view is the shape the authoring
guidance recommends over a projection. -
A projection field that RENAMED the base field built a view over a column that does not
exist.{ field.string: { name: bookingRef, extends: "Shipment.reference" } }emitted
SELECT s.booking_refagainst a table whose column isreference: the view's output alias
and the column it selects were both derived from the projection's field, and theextends
target was never consulted. SQLite does not resolve a view body atCREATE VIEW, so
meta migrate --applyreported success and the view failed at the firstSELECT;
Postgres rejected the migration outright. The source column now comes from theextends
target, and only when that target names the BASE entity — a bareextendsonto a
package-level abstract is shape reuse, not a column binding, and a read-view HOST reads
its own table, where a dottedextendsonto a sibling field is shape reuse too. Both
still resolve to the field's own name. Renaming a base column is one of the two things the
authoring guidance names as genuinely forcing a projection. An adopter whose view had a
renamed passthrough will see a view-replace in the nextmeta migratediff — that is the
corrected SQL, and the view it replaces could not be queried. -
meta ejectandmeta gen --listprint an install command you can paste. The line
was documented as paste-ready and was not, on the most common selection
(meta eject entity queries routes names barrel):- a peer range such as
drizzle-orm@>=0.36.0 <1.0.0was unquoted, so a shell read it as
a redirect. The pasted line exited 1 and left a file named=0.36.0behind. Ranges are
now single-quoted. - a package two selected generators need was listed twice when only one of them knew its
range (drizzle-ormanddrizzle-orm@>=0.36.0 <1.0.0). The set is keyed by package,
and the ranged spec wins.
- a peer range such as
-
meta verify --codegenno longer calls a stale<Entity>.names.tshand-edited. Rename
a column in the model and skipmeta gen, and the gate convicted the names artifact as
"hand-edited". Its only remedy was "'meta gen' will NOT help". Nobody had edited the
file, andmeta genwas exactly the fix. The entity module reads its column names from
that artifact, so for a column rename the names file was often the only file reported,
and the right remedy never printed. A names file still byte-identical to whatmeta gen
recorded writing (.gen-state/.hashes.json) is now reported as ordinary stale output with
themeta genremedy. An edited one, or one with no recorded hash, is still convicted as
before. -
libraries: ["iam/db"]could not migrate at all. The library named four
identity.secondarynodesuqKey— legal metadata, and unambiguous per entity, but
index names are unique per DATABASE, so the firstmeta migratein any project that
opted in died onERR_DUPLICATE_SQL_NAMEbefore writing a table. They are now
uqGroupTypeKey,uqGroupKey,uqRoleKeyanduqPermissionKey. Nobody can have
applied the old schema, because the migrate that would have created it is the one
that failed. The shipped-library gate now builds a sqlite AND a postgres schema for
every library db layer and asserts index names are distinct — loading and verifying,
which is all it did before, never build a schema. -
A requirement whose implementing member was renamed no longer says to qualify the object.
RenamedueDatein the model and a live claim onapp::Task.dueDatefailed with
ERR_REQUIREMENT_DANGLING_REF, then added "An object named "Task" exists in: app::Task.
Qualify it with its package (FQN)." The ref was already qualified and the object resolved;
only the member was gone. The error now ends "'app::Task' has no member 'dueDate'.", and a
nested ref names the hop that broke ("'app::Task.title' has no member 'display'."). The
did-you-mean hint still appears when the object itself does not resolve. -
C#: a
field.decimalreached through a nested object generated code that did not
compile.ExtractDelegateEmitter.ScalarReader— the reader map for the runtime-DELEGATING
extract mirror — had noDecimalbranch, so a decimal fell through to theDlgString
default whileFr010FieldMapping.ScalarMirrorTypetyped the same mirror propertydecimal?:
error CS0029: Cannot implicitly convert type 'string' to 'decimal?'. A decimal array
broke the same way withCS0266(IReadOnlyList<string?>→IReadOnlyList<decimal?>),
because the delegating mirror kind-types array elements throughScalarMirrorTypetoo. Only
the delegating path was affected — that is the path for a field reached through a nested
object or an array-of-objects, so a flat top-level payload went through the self-contained
reader, which always had its Decimal branch. Fixed by adding the branch plus aDlgDecimal
helper mirroringExtractMap.AsDecimal's never-throws contract (an out-of-range double
degrades to null rather than throwingOverflowExceptionmid-parse, which is the point of a
lenient extract).ScalarMirrorType's doc-comment had claimed the self-contained and
delegating mirrors were "in lock-step" with nothing testing it; a new gate now compiles
generated output for every scalar subtype inFIELD_SUBTYPES, in both the single and the
array position, so a newly registered subtype cannot quietly take a mismatched default. -
C#: the generated
AppDbContextfailed to compile when an entity name collided with a
DbSetproperty name. The reference-FK configuration named its column through
nameof(<Owner>.<Prop>), an expression sitting inside theDbContextclass body — where C#
simple-name lookup binds<Owner>to a member of the context before it considers a type of
the same name, and the context declares oneDbSetper entity. Reachable from stock
metadata, not just exotic input:Pluralize("Address") == "Addresses", so a model carrying
both anAddressand anAddressesentity emittednameof(Addresses.AddressId), bound
AddressestoDbSet<Address>, and failed withCS1061. Now emitted as a typed lambda —
.HasForeignKey(e => e.AddressId), or.HasForeignKey(e => new { e.OrgId, e.SiteId })for a
composite — whose parameter is local and therefore unshadowable, and which unlike a bare
string literal keeps the property name compile-checked. The M:NUsingEntity<Through>(...)
sides carried the identical hazard and were converted too. -
meta init --refresh-docsno longer reports a scaffold it did not perform. On an
established project the refresh-only path did the right thing — agent context refreshed in
place, metadata,.metaobjects/config.json,codegen/generators/and
metaobjects.config.tsall left alone, nothing created — but then printed the full first-run
banner: "Initialized metaobjects/ + .metaobjects/ + metaobjects.config.ts", "Codegen
generators copied to codegen/generators/", and next-steps telling you to set
"type": "module". It read as though the command had just scaffolded over your project. The
output now reports the refresh and states what was left untouched.--refresh-docson a
repo that is not yet initialized still falls through to a full init and still prints the
scaffold banner, so the branch keys off what actually happened rather than off the flag. -
Java: the generated extract mapper did not compile for most scalar subtypes. A
respondingtemplate.prompttypes its<Template>Responserecord through
SpringTypeMapper.javaTypeName, which is richly kind-typed (BigDecimal,LocalDate,
LocalTime,Instant,UUID,URI,InetAddress,Float,Longfor currency) — while
SpringOutputParserGenerator's mapper recognised only Integer/Long/Double/Boolean and fell
through toExtractMap.asStringfor everything else, with every scalar array going to
asStringList. Each mismatch isincompatible typesat compile time: 9 of 15 scalar
subtypes as a single component, 13 of 15 as an array. It shipped because no test payload in
the module carried a decimal, date, time, timestamp, currency, uuid, uri or inet — and
because javac reports only the FIRST bad argument of a constructor call, so even a fixture
with several would have looked like one defect (which is exactly how it was first reported,
as a decimal-only bug). The record stays strictly typed per ADR-0052; the generated parser now
coerces on the way in, with never-throws helpers emitted only where used. A new per-subtype
gate compiles generated output for every subtype in both positions. -
Java:
MetaObjectExtractor.assembleviolated its own documented never-throws contract.
Its javadoc promises the assembled object comes back with malformed data already classified
into the report and no exception — but the per-field write goes throughDataConverter, which
throws on a value it cannot convert. One unconvertible component therefore destroyed the
entire extract, the opposite of the lenient tier's purpose: a model answering"31/12/2026"
for one date should cost you that date, not the twenty fields beside it that parsed. The
component is now left unset, which is what "lost" already means everywhere else in that pass. -
Java: an unparseable decimal became
0instead of null.DataConverter.toBigDecimal
caught theNumberFormatExceptionand returnedBigDecimal.ZERO— the one wrong answer
available, because0is a plausible amount, so unreadable input reached a money field as a
real-looking value with nothing to distinguish it from a genuine zero. Every sibling
converter (toInt/toLong/toDouble/toFloat) throws for the same input; decimal
was the lone exception. It now throws too, and the lenient extract tier's never-throws guard
turns that into a lost component — so the answer there isnull, which is the honest one. -
Java/Kotlin: a declared
field.decimal @isArraywas silently dropped.DataTypeshad no
DECIMAL_ARRAY, andarrayTypeFor(DECIMAL)mapped decimal to ITSELF with a comment saying
arrays-of-decimal "aren't in the metamodel" so the declaration would "degrade gracefully". It
did not degrade gracefully: the loader accepts the declaration, all five ports generate code
for it, and C# populates it correctly (now pinned by value in
Fr010DelegatingMirrorLockStepTests) — so this was a JVM gap presented as a design position,
not a metamodel rule. AddedDECIMAL_ARRAYplus atoBigDecimalArraythat element-converts
throughtoBigDecimalrather than a double, so precision survives the round trip. The new
member takes id 22 rather than a slot besideDECIMAL=10: those ids are persisted
identifiers, and renumbering the existing members to keep the enum tidy would silently
repoint stored data. Kotlin inherits the fix — it consumes the samemetadatamodule — and
C#, TypeScript and Python needed no change (that conversion table is JVM-only; TS binds
field.decimaltostringand Python toDecimal). -
Java:
DataConverter.toDatecould not parse an ISO-8601 date. TheStringarm was a bare
Long.parseLong, so it accepted epoch milliseconds and nothing else — a plain"2026-03-04"
threwNumberFormatException. Since that is the formnormalization.mdputs on the wire, no
field.dateorfield.timestampcould be assembled from its own wire representation. It now
accepts epoch millis (tried first, and only for an all-digit string, so every value that
parsed before still parses identically) plus ISO instant, zone-less date-time, and date-only
forms. Unparseable input still throws, preserving the failure mode for callers outside the
lenient tier.
Added
-
C#:
DbContextGenerator.EmitsReferenceForeignKeys, an opt-out for the reference-FK
configuration. The navigation-lessHasOne<Target>()overload that configuration uses is
correct because the stock entity generator emits no reference navigations (ADR-0038
replaced reverse navigation with explicit FK finders) — a premise this generator cannot
detect, and one that does not hold for an adopter who substitutes their own entity generator
and emits navigations. There, EF's conventions discover those navigations and build their own
relationship over the same FK column, and a navigation-less config claiming that column
leaves the convention-built one unable to identify its dependent ("The dependent side could
not be determined for the one-to-one relationship between 'X.Y' and 'Y.X'") — failing model
validation, which takes down every query in the application rather than just that
relationship. Override the property tofalseand configure those relationships yourself;
nothing else the generator emits changes. Documented in
relationships.md. -
The extract engine's
FieldKindvocabulary is now gated across ports
(scripts/check-extract-field-kinds.mjs,gateslane). Every port's extract engine runs
fixtures/extract-conformance/, and that corpus'sschema.jsonkindvalues ARE this
vocabulary — so a port that adds or drops a kind changes what the shared corpus can express
while every existing fixture keeps passing. The drift is invisible by construction, and it had
already happened: C# carries aDecimalkind no other port has and no fixture exercises. The
gate reads each port's real definition rather than a copied list (an unrecognised declaration
shape fails loudly instead of reporting an empty set, which would read as "no drift"), and it
fails if Kotlin ever grows its ownFieldKind— it ships no extract engine, driving the shared
Java one. Deviations pass only when
expected-field-kinds.jsonrecords
them with a reason; the C# one is recorded there, including why deleting it is not the
one-liner it looks like (ExtractMap.AsDecimalhas no string arm, so an engine that stopped
producingdecimalwould silently return null for every decimal). A recorded deviation that
is no longer true also fails, so the record cannot go stale.