Skip to content

1.0.5

Choose a tag to compare

@dmealing dmealing released this 23 Sep 13:38
· 318 commits to main since this release

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.

  1. meta gen is 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 the payload generator, and delete the files that are no longer emitted —
    meta gen never deletes one, and meta verify --codegen names them. Full list:
    migration guide.
  2. Two metadata shapes stop loading. (a) An M:N junction whose two
    identity.reference children 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
    now ERR_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.
  3. The filter/sort 400 envelope gained a required field member 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 of meta gen --list, dotnet meta gen --list and metaobjects gen --list; the README, the docs and the shipped metaobjects-codegen skill 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) and dotnet 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.prompt now gets a generated render helper in C#, Java, Kotlin
    and Python — previously only TypeScript did.
    Each of those four ports' render-helper
    generator filtered template.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 separate prompt-render generator, and that split is
    now stated in fixtures/generator-registry-conformance/registry.json rather 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-running meta gen and 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 moves metamodelVersion: registered vocabulary, the canonical/interchange format,
    the wire contract. This supersedes three rows of docs/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 — the payload generator 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, the prompts.ts interfaces)
    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 — TS entity, C# entity, Java
    value-object, Kotlin entity, Python entity — emits one type per concrete
    object.value and sourceless object.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. Remove payload from C#/Python
    --generators and SpringPayloadGenerator / KotlinPayloadGenerator from Maven
    <generators>. Delete generated files that are no longer emitted (meta gen never deletes
    one; meta verify --codegen names them) — including a TS prompts.ts that 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 are name?: T (not T | null) and the removed exports are
    generatePayloadInterfaces, generatePayloadInterfacesBatch and generateRenderHandle;
    C# payloads are the <Vo>.g.cs POCOs with PascalCase members, and the extractor is
    <Prompt>Extractor; Java drops the generated hasFoo() methods (the render engine derives
    them) and now collides entity with value-object on 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 — field is
    REQUIRED, not optional.
    invalid_filter_field, invalid_filter_op,
    invalid_filter_value and invalid_sort each carry
    {"error": "<code>", "field": "<name>"}. Before this, TypeScript sent field and 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 run meta gen (dotnet meta gen
    / mvn metaobjects:generate / metaobjects gen): C#, Java, Kotlin and Python all emit a
    changed list handler, and the JVM/C# FilterParseResult gains a field member (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/ asserts field on
    all four codes, in both the reference and generated lanes of all five ports, and a new
    filter-invalid-value scenario 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
    no field and 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 the field change 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.
    @through has always been documented
    as a junction declaring "two identity.reference children, 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 with ERR_INVALID_RELATIONSHIP.

    This is a correction, not a redesign, and it ships as a PATCH under the three-part test
    in docs/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. metamodelVersion stays
    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 its identity.reference children 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 by fixtures/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 shape xpkg-m2n-collision has 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/@through now 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 @references resolves in
    the junction's package and the relationship's own @objectRef in the declaring entity's,
    because under extends across 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 → authors under every old rule, and
    every collection base in every corpus was such a word, so all five lanes were green
    while OrderSummary was 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, orders are 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/sh takes es, a consonant before y becomes ies (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, so OrderSummary is /order_summaries either way.

    It is gated rather than merely documented: fixtures/api-contract-conformance/m2m/
    declares PostCategory — 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 (RouteNaming in codegen-base) instead of
    two hand-maintained copies; Python's second private copy of the rule in
    m2m_codegen is gone the same way. The acronym case is pinned by unit test per port,
    since no corpus entity carries one.

  • meta types <construct> --detail no longer drops rows from a query that has already
    narrowed.
    meta types field.enum --detail printed "20 of 22 shown — narrow with
    QUERY/--type/--kind or raise --limit", having dropped @values and @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 bare meta types does 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 --limit is an instruction and is still
    obeyed exactly.

  • The registered requirement.functional and requirement.architectural descriptions
    now say what meta verify enforces.
    They said a functional requirement "fails when
    nothing implements it" and an architectural one "fails when something VIOLATES it". In
    fact a live/partial functional 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.txt and the audit skill
    also stop saying the verify checks run in every port: the vocabulary loads and
    validates in all five, and the checks run in the Node meta CLI.

Added

  • C#: dotnet meta eject <name>... copies a reference generator's source (shipped
    embedded in MetaObjects.Codegen) into codegen/generators/, renaming only its namespace,
    and on first use scaffolds an owned codegen/ console project that lists the generators
    and calls a new public runner (CodegenCli), which GenCommand now uses too. Once that
    project exists, dotnet meta gen and dotnet meta verify --codegen hand 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 --generators still runs from the
    package. It finds metadata the way the tool does, libraries included, and the scaffold
    pins MetaObjects.Codegen at the tool's published version, pre-release suffix included.
    (The metadata-location ladder moved into MetaObjects.Config.MetadataLocation so the tool
    and an owned project share it.) Eject
    never overwrites a copy without --force and never edits the scaffold after writing it;
    --list marks owned copies. The helpers a copy needs are now public (Fr010FieldMapping,
    FindInbound, ExtractDelegateEmitter, OutputFormatSpecEmitter,
    ValueObjectNames.Names and others).

  • Java and Kotlin: mvn metaobjects:eject -Dnames=<a,b> copies reference generators
    into a codegen/ 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 writes codegen/pom.xml on first use, never edits an existing pom,
    and prints the <module>, plugin dependency and <classname> to wire. -Dlist prints the
    catalog with owned copies marked. The written pom builds on its own: it sets Java 21 (and
    Kotlin's JVM target 21) and pins kotlin-maven-plugin to 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's entity,
    extractor and template are not. To make every copy compile against published
    artifacts, SpringRecordBuilder, OutputFormatSpecEmitter,
    SpringTypeMapper.isLenientNetField, KotlinEnumEmitter, Fr019SharedEnum,
    KotlinExtractSchemaEmitter and KotlinOutputFormatSpecEmitter are now public.

  • Python: metaobjects eject <name>... copies a reference generator into
    codegen/generators/ to own and edit. Wire the copy in generators (or --generators) as
    module:symbol, the form providers already takes; gen and verify --codegen then run
    your copy. Eject never overwrites a copy without --force and never edits the config, and
    metaobjects gen --list marks owned copies identical or DIFFERS: 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>Repository of 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).
  • 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}/_meta document: 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 resolve extends itself (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 own META_ROUTE_PATH constant
    ("/_meta"): TypeScript's metaJson(root) (@metaobjectsdev/runtime-ts), C#'s
    MetaEndpoint.MetaJson(MetaData root), Java's MetaEndpoint.metaJson(MetaData root)
    (reachable from Kotlin through the metadata-ktx facade), and Python's meta_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-web ships loadMetaModel(), a plain
    structural reader (no registry, no validation, no extends resolution) that turns a
    fetched /_meta document into the narrow MetaRead / MetaFieldRead / MetaLayoutRead
    / MetaModelRead surface the existing buildGrid() already consumes — so a grid built
    from a live document and one built from a real loaded MetaObject produce byte-identical
    output, gated by a parity suite that runs buildGrid() over both. Two TypeScript barrels
    widened to support it: the @metaobjectsdev/metadata package root now exports
    canonicalSerializeEffective, and its browser-safe /constants subpath now re-exports
    shared/base-types and shared/structural as well.

    See docs/features/metadata-api.md for 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 --db diffs
    against the same builder that wrote the migration, and a dropped .references()
    compiles. A new oracle in integration-tests restates from the metadata alone which
    table stores each object's rows, its columns and FKs, and the routes served, and reads
    the answer from pg_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. See docs/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 by 1.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 an AllTypes entity 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
    (zero DiagnosticSeverity.Error), javac via ToolProvider, KotlinCompilation, and —
    Python having no static compiler — importing the generated package, which is what resolves
    the package-relative imports BETWEEN modules, plus ruff F821 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#'s IncludeNames has a recorded CS0103;
    Java's entity and value-object both emit a type for an object.value). Every port
    excludes its framework-bound route tier
    — TS routesFile, C# RoutesGenerator, Java
    SpringControllerGenerator, Kotlin KotlinSpringControllerGenerator: 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 verify rejected @description on every node, including the shipped
    iam and ai libraries' 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 gen loads lax
    and let them through unvalidated; verify loads 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 running dotnet meta verify --codegen against an adopter estate.

  • gen no 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), and gen says so in one warning. Delete any ../-keyed entries from a
    committed manifest by hand; nothing reads them.

  • Python: the entity generator's catalog entry and the install docs now name
    pydantic[email].
    A field with @stringFormat: email — the shipped iam library's
    User.email is one — is typed EmailStr, which imports email-validator when the model
    class is defined. The documented install was pip install "pydantic>=2" fastapi, so a
    clean install of a project using iam failed 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 --codegen load 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.enum generates 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 @intValueMap the metadata already declares — and
    the obvious ordinal() compiles, round-trips within one process, and writes rows no other
    port agrees with. An int-backed enum now carries dbValue() and fromDbValue(int), which
    throws on an unmapped stored value; a string-backed enum is unchanged. Found by an adopter
    estate.

  • Maven: metaobjects:generate registers its output as a compile source root. It never
    did, so mvn compile in 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 under generate-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 --db printed
    dialect 'postgres' requires 'pg'; install it: 'npm install pg', but the driver must
    resolve from where @metaobjectsdev/cli is 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.json now 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 a sources entry shows
    the form that works: { "path": "model" }. Found by an adopter estate.

  • meta export loads the libraries a project opts into. It loaded the resolved file
    list alone, so under export every reference into an opted-in library (for example
    metaobjects::iam::User) failed with "does not resolve to an object" while gen,
    verify and migrate loaded 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 became ALTER TABLE … RENAME TO, but its CREATE INDEX and
    ADD CONSTRAINT statements 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 a drop-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 with D1RenamedTableRebuildError, 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-helper generator could not be pointed at your templates.
    GeneratorEntry.factory took no arguments and the registry's render-helper factory
    hardcoded template_root="templates", so metaobjects gen's --templates <dir>
    reached the --template-spec pass and nothing else. The generator was registered,
    listed by --list, selectable by name — and resolved every @textRef against 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 the metaobjects console script does not expose.
    Fixed by porting C#'s GeneratorBuildContext rather than inventing a second shape:
    every factory now takes one, and gen, the per-target config path and
    verify --codegen all fill it from one resolver — because a verify that
    resolved the root differently would regenerate render-helper elsewhere and convict
    the committed helpers of being stale, with a remedy that loops. --templates is 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 to templates/). The Node CLI has always defaulted to prompts
    (DEFAULT_PROMPTS_DIR), while dotnet meta gen and metaobjects gen defaulted to
    templates, so a project laid out the way the Node CLI documents — this repo's own
    adopter estate among them — had a gen looking where its bodies were not. A
    fallback, not a flip:
    prompts/ must EXIST before it wins, so a project whose
    bodies are in templates/ 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 in gen and in verify --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 children entry that is not a node no longer loads — four ports, four answers,
    now one
    (new ERR_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 (under ERR_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, so meta gen emitted the
    table without its column — while meta 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 by fixtures/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 answering ERR_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 on strict.

    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 no meta upgrade --apply for 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 reported ERR_MISSING_SUBTYPE — "write the full madeup.<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. Now ERR_UNKNOWN_TYPE at 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-base as 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 said ERR_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 by fixtures/conformance/error-root-not-object, which no
    fixture covered before.

  • TypeScript: a template whose name starts lowercase generated code that does not
    compile.
    The extractor generator emitted import { extractLenient<rawName>WithLoader }
    while the output-parser generator exports extractLenient<CapitalizedName>WithLoader, so
    a template.prompt named shipmentRisk produced a module importing a symbol nothing
    declares — meta gen exits 0 and the adopter's tsc is the first thing that disagrees.
    The cause was one split, not a typo: render-helper, output-parser and prompt-render
    route the template name through templateSymbolBase() while extractor, output-prompt
    and the api-docs model interpolated it raw, so a single model emitted renderShipmentRisk
    beside rendershipmentRiskFormat and extractshipmentRisk. Every site now uses
    templateSymbolBase(), which also stops meta 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 renderHelperName always was.
    responseFormatName, parserName and extractorName interpolated the template name raw,
    so a lowercase-initial template produced object coachNoteParser beside
    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 responding template.prompt named coachNote
    (deliberately lowercase-initial, deliberately carrying @responseRef) plus its three value
    objects and the mustache it renders; the nodes are sourceless, so canonical/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.timestamp reached the wire as Postgres' own text,
    "2026-09-20 12:00:00+00", not ISO 8601.
    docs/features/api-contract.md (and
    normalization.md behind it) promise YYYY-MM-DDTHH:MM:SS[.fff]Z, always UTC, and the
    other four ports answer the same row that way. Codegen maps a Postgres field.timestamp to
    timestamp(…, { mode: "string" }), and in string mode Drizzle returns exactly what
    node-postgres received: Postgres' OUTPUT format, with a space for the T and 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 in column-mapper.ts claimed 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 @localTime column keeps its
    wall clock with no Z. 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 .000 that Date#toJSON pads.
    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 exported timestampWire(table)
    (@metaobjectsdev/runtime-ts/drizzle-fastify or /hono). Two limits, stated: SQLite and
    D1 store a field.timestamp in a plain text column, so the value goes back as it was
    written; and a projection over an opaque @sql view has no declared columns to read.
    No api-contract scenario could see this: none asserts a timestamp literally, and the
    runners normalize createdAt before comparing. An adopter estate's strict client found
    it. It is now gated on a real Postgres whose session runs in America/New_York, so
    relabelling +00 as Z would not pass. The test covers both adapters and both entry
    points (runtime-ts/test/timestamp-wire-pg.test.ts). ci-local.sh's ts-slow lane now
    runs that file and read-only-raw-sql-pg.test.ts. The second file needed a database and
    had skipped everywhere.

  • TypeScript (Hono): an empty PATCH body was a 500. @metaobjectsdev/runtime-ts/hono's
    update handler passed {} straight to Drizzle's .set(), which throws No values to set.
    The redaction path turned that into 500 {"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's patch-empty-noop scenario never reached Hono.

  • Kotlin: a generated builder could emit Kotlin that does not parse (#388). The
    build() function's requireNotNull(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, so acme::alpha::Note and acme::beta::Note — or a value
    object and an entity sharing a name — collided. A colliding value object is now
    package-qualified (AcmeAlphaNote.py), and field.object / field.map references 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's buildFilterQs — 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's FilterParser decoded with java.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 (%XX is 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 by FilterParserDecodingTest, a
    Kotlin controller run test that hands the request over the way Tomcat does, and now the
    api-contract corpus's filter-like-raw-percent scenario in both lanes of all five ports
    (the C# runners put the raw % on the wire — .NET's Uri had been rewriting it to %25
    — and the JVM generated lanes send it verbatim to an embedded Tomcat). Kotlin
    adopters with committed generated code should run mvn metaobjects:generate
    — the list
    handlers now take HttpServletRequest instead of @RequestParam allParams. Java needs no
    regen: the fix is in the codegen-spring runtime.

  • Java and Kotlin: a TPH base's list route now filters by the discriminator.
    GET /api/parties?filter[partyType][in]=CARRIER,BROKER answered
    400 invalid_filter_field on 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 misses in and ne
    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 run mvn metaobjects:generate. Gated by
    the api-contract tph/ scenario tph-base-filter-by-discriminator (eq, in and 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 @required key 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 shared BaseEntity declares id
    required). An increment key hid it — a missing Long binds 0 — but a UUID cannot.
    Behind that sat a second defect: the create handler read a @generation: uuid key 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 @autoSet column 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 run mvn metaobjects:generate;
    code that read entity.id as 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
    generated handleConstraintViolation was declared for RuntimeException, and Exposed raises
    a violation as ExposedSQLException, which extends the CHECKED java.sql.SQLException — so
    the handler never matched and Spring answered every duplicate key and dangling reference with
    a 500. It now handles java.sql.SQLException too. Nothing had run the handler: it existed
    only in snapshots. Run mvn 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,
    so filter[status][eq]=DELIVERED compared '30' to 'DELIVERED' — eq and in matched
    nothing and ne matched 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 import exposed-java-time and exposed-json, and Database.connect(...) needs
    exposed-jdbc at runtime; docs/ports/kotlin.md listed only exposed-core. Its generator
    table now lists all three, and says the controller needs KotlinRelationsGenerator whenever
    the model has a M:N relationship (the traversal routes call helpers only that generator
    emits). A KotlinSpringConfigGenerator run with validatorEnabled=false no longer emits a
    KDoc claiming the validator runs.

  • Python runtime: a field.currency read 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 an int. A view's need not be: SUM over BIGINT is Postgres numeric, read back
    as a Decimal, and a FastAPI route serializes a Decimal as a string — so
    GET /api/shipment_summaries answered "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 to int whatever the column type; a fractional one (an
    avg over 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 for subtype=None each row carries its OWN subtype's fields, and
    how to get them. Generated output changes (a docstring only) — run metaobjects 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
    failed SourceDir [src/main/metaobjects] does not exist in any multi-module reactor build
    run from the parent (Maven keeps the root as the working directory for every module) and
    under mvn -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-ts writable mounts answered PUT with a 404.
    The cross-port REST contract (FR-008) makes the update verb reachable via BOTH PATCH
    and PUT, 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 registered PATCH alone unless updateMethod said
    otherwise, so a PUT the 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 OWN PUT handler at <path>/:id beside 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 PUT on a writable entity. The
    generated update route serves PATCH and PUT from one handler in all five ports; C#
    and Python documented both, the other three documented PATCH only, so an adopter (or
    an agent reading meta docs --agent) was told an address the server answers did not
    exist. Each builder now emits a PUT row beside PATCH, 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 the identity.reference / identity.secondary that
    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.ts and C#'s ConstraintErrors — 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
    DataAccessException whose 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's DbUpdateException. SQLException#getNextException() is walked too, because a
    batch failure puts the useful exception there rather than on getCause(). 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 / errno the 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 @ExceptionHandler on the generated
    controller instead of a try/catch per verb. An Exposed transaction { } 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/400 for 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
    SpringDtoGenerator emits 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, since identity.primary @generation: uuid|increment means 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_CREATE set (primary-key components plus
    @autoSet columns) and filters violations by top-level property name. validator.validate(dto)
    is KEPT rather than narrowed to per-property validateValue, because it is what cascades
    @Valid into nested value objects — the violations are filtered, not the validation weakened,
    and a nested path like labels[0].code can 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 declares Author.id with 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
    BaseEntity declares id required — the shape real models actually have. Now gated: the
    jsonb/ sub-corpus's Document.id is @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.create refused a record that omitted a @required,
    server-generated primary key.
    resolveIdentity knew an @generation: increment key is the
    store's to fill — it resolves it as driver-generated — but the required check that followed
    ran over the merged row, found no id, and threw ValidationError (id: required). So every
    create through ObjectManager, including the ObjectManager-driven Fastify adapter
    (@metaobjectsdev/runtime-ts/fastify), answered an entity whose BaseEntity declares id
    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 @default column; an explicit
    null is still a required failure. createMany follows 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 the source.rdb declarations. The Maven plugin validated a
    pom's tokens against LibrarySources.knownPackages(), which answers with the library
    NAMES ([ai, iam]), instead of knownTokens(), 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 with names 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 no source.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.enum that extends another entity's enum field generated code that does
    not compile.
    extends on a field.enum has two very different readings and only one
    materializes a type: FR-019's shared enum is a ROOT-level ABSTRACT field.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 Status beside its own public Status Status { get; set; } — CS0102, a
    member and a nested type with one name. meta gen exits 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
    @provided enums are byte-identical.

  • C#: a read-only projection's generated routes named a filter allowlist nothing emitted.
    RoutesGenerator emits the FilterParser.Parse(qs, <Cls>FilterAllowlist.Fields, …) line
    for a projection's list route like any other read, but FilterAllowlistGenerator skipped
    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>FilterAllowlist a routes file REFERENCES is emitted. A
    projection with no @filterable field 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 while GET /parties did not. The base class now
    carries a [JsonDerivedType(typeof(<Sub>))] per concrete subtype. No $type key 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.timestamp crossed the wire as +00:00 rather than the documented Z.
    docs/features/api-contract.md spells a timestamp ISO 8601 with the UTC designator;
    System.Text.Json's default for DateTimeOffset renders 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 same timestamptz row read by five ports, and two spellings are two
    answers. MetaObjects.Codegen.Runtime.Iso8601TimestampConverter writes UTC with Z
    (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 on ConfigureHttpJsonOptions beside
    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; reaching Z needs the UtcDateTime).

  • C#: a constraint violation answered 500 instead of 409. A generated CRUD route had no
    try/catch around SaveChangesAsync, 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 — answers 409 {"error": "constraint_violation", "constraint": "foreign_key" | "unique"}, with check and
    not_null as 400. MetaObjects.Codegen.Runtime.ConstraintErrors is the C# half of
    runtime-ts's constraint-errors.ts, algorithm for algorithm: it walks the exception chain
    (EF wraps every provider failure in a DbUpdateException whose 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 gen keyed its hash manifest by the OUT DIR, so a second --out under
    one project could destroy a hand edit in the first.
    .gen-state/.hashes.json is 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 next gen overwrote
    it. That is the exact failure the manifest exists to prevent, reached by running gen in 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 threw No values to set. The throw was
    caught, and classifyConstraintError correctly declined it — an empty update is a
    programming error, not a constraint violation — so it took the redact path and answered
    500 database 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 400 validation from their base
    AuthorApiServer while TphReferenceServer/JsonbReferenceServer in 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, 404 when
    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-SET guard: the @autoSet:onUpdate
    stamp was already being appended unconditionally right after it, so dropping the guard makes
    the SET non-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.yaml asserts 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-argument Object method
    (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 @autoSet stamping helpers, the <Entity>Patch
    (a plain class, where a String-typed toString() COMPILED and silently overrode
    Object.toString()), the abstract-shape interface, and — at run time — the property-name
    string the generated PATCH handler passes to Validator#validateValue, which resolves a
    record's property by its component and threw IllegalArgumentException on the declared
    name. This is a Java restriction and it stops at Java: notify stays 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 the Object-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. The entity generator prefixes its accessors
    (getX/isX/setX), which shields the method NAME but not the setter's parameter, so a
    field named class emitted setClass(String class), plus a getClass() 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 declared String 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 @JsonProperty exactly as for the Object-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 .java was written unguarded, and was then mistaken for hand-written
    work.
    MultiFileDirectGeneratorBase streamed straight to a FileOutputStream, 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 no GENERATED marker, so
    a sibling generator that DOES go through the guard read the output as somebody's own and
    declined. own-your-codegen.md promised the opposite on this port for the bulk of its
    output. Now buffered and routed through GeneratedFileWriter, 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
    runGen and Python's run_gen both refuse a conflicting duplicate path; Java has no runner,
    so nothing was in a position to notice. This was live: entity and value-object both emit
    a Java type for an object.value at 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.inet column emitted a support file that never
    type-checked.
    MetaInetUriColumnType.kt's setParameter assigned
    if (value is InetAddress) value.hostAddress else value — an Any? — into stmt[index],
    which takes a non-null Any. 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 to super.setParameter, which
    knows how to bind it. Nothing caught it because the existing text-matching test passes on a
    file that cannot compile.

  • meta migrate dropped every foreign key onto a TPH subtype, and meta verify --db could
    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; an identity.reference onto Carrier (a subtype of
    Party) resolved to nothing and the constraint was silently left out of the migration.
    verify --db diffs 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 see add-fk (and possibly add-column) changes in the next meta migrate
    diff
    — those are the constraints and columns the metadata always declared.

  • A TPH subtype-only column carried a DB DEFAULT it 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's forceNullable contract also
    suppresses the default. So meta migrate emitted 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 --db could 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 @default on a subtype-declared
    field: the next meta migrate diff drops that default.

  • A @cardinality: one relationship declared on a TPH subtype (or an abstract mid level)
    never reached a relations() 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 through tphStorageName, 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-run meta 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>Relations symbol — 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 while meta gen exited 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(), the relations()
    entry and the M:N traversal all imported carriers from Carrier.ts (TS2724). They now
    bind the discriminator base's table (parties from Party.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.
    mountM2mRoute takes a new optional targetDiscriminator: { 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 helpers tphStorageObject / tphStorageName answer "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.
    mountM2mRoute takes a new optional sourceDiscriminator: { 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's tph-m2m-routes entry
    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 with db.<Base>.OfType<Sub>().AnyAsync(...) before touching the
    junction; an M:N whose TARGET is a TPH subtype binds to the base's DbSet narrowed by
    OfType<Sub>(), because a subtype has no DbSet of its own and the emitted routes
    file previously did not compile; a TPH subtype-only scalar carrying both @required
    and @default now 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: false never emits. Python reaches it by its own route too: it
    ships no runtime SQL layer (the repository Protocol is consumer-implemented), so the
    subtype-scoped mount composes the seam's own find_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 a sourceDiscriminator
    stage 0; and an M:N whose TARGET is a TPH subtype WIDENS the
    find_related_<relation> seam with a target_subtype parameter carrying the
    resolved @discriminatorValue — threaded per relation NAME across every mount of
    that name in a hierarchy (str | None with an explicit None at 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 FAILS meta gen (M2MDerivationError from
    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 Exposed Table object 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 new KotlinTphPlan.storageObjectOf to the base's table
    instead of naming a BridgeAuthTable that 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 next meta 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, so Organization.queries.ts
    imported an organizations table that does not exist. They now gate on servesReadApi,
    the predicate the TanStack hooks already used, so routes, queries and hooks exist for the
    same objects again; the API docs follow. If you ejected queries.ts, routes.ts or
    routes-hono.ts
    , change hasAnyRdbSource(e) to servesReadApi(e) in its filter (both
    are exported from @metaobjectsdev/codegen-ts) or re-eject, then delete the stale
    <Abstract>.queries.ts / .routes.ts files the old generator left behind.

  • loadMemory(repoRoot) did not load the libraries a project opts into. When called
    with no files, it took the files, file ids and dependency imports from
    .metaobjects/config.json through resolveCollection, but not libraries, which FR-043
    moved into the same file. A project opting into iam loaded through meta and failed
    through the SDK: every reference into the library was ERR_INVALID_REFERENCE, and the
    library-package guard never ran. It now takes the selection from the collection too;
    an explicit libraries option 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 a SyntaxError when
    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, while meta migrate dropped 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>Schema the 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 admit null for 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 asked fieldWillBeOptional (not-required OR
    has-a-default) on the read path too, widening a NOT NULL DEFAULT column to | null and —
    through the .optional() that predicate mirrors — to | undefined, while the declared
    interface keyed its ? on bare @required and said T | null: the value parse<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 @default is correct there. Generated output changes: a TPH
    subtype's read schema (re-run meta gen); the feature-combination ledger's
    tph-required-default-read-type entry is drained as a result.

  • @dbColumnType: jsonb emitted a SQLite column that did not match its own type. The
    open-JSON-bag escape hatch types the field unknown in Zod and TS, because the value
    round-trips as a PARSED JSON value on every dialect. On SQLite the attribute fell through
    to a plain text() column typed string, 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 form field.object and field.map
    already take on that dialect. Postgres is unchanged. Generated output changes on SQLite.

  • A view-backed read model carrying an int-backed field.enum generated a module that did
    not compile.
    A @intValueMap enum persists as an integer column, and the entity table
    emits a module-local Drizzle customType codec 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 with import { statusIntEnum } from "drizzle-orm/sqlite-core" — a member
    that package does not export. meta gen exited 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 — an object.projection and 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_ref against a table whose column is reference: the view's output alias
    and the column it selects were both derived from the projection's field, and the extends
    target was never consulted. SQLite does not resolve a view body at CREATE VIEW, so
    meta migrate --apply reported success and the view failed at the first SELECT;
    Postgres rejected the migration outright. The source column now comes from the extends
    target, and only when that target names the BASE entity — a bare extends onto a
    package-level abstract is shape reuse, not a column binding, and a read-view HOST reads
    its own table, where a dotted extends onto 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 next meta migrate diff
    — that is the
    corrected SQL, and the view it replaces could not be queried.

  • meta eject and meta gen --list print 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.0 was unquoted, so a shell read it as
      a redirect. The pasted line exited 1 and left a file named =0.36.0 behind. Ranges are
      now single-quoted.
    • a package two selected generators need was listed twice when only one of them knew its
      range (drizzle-orm and drizzle-orm@>=0.36.0 <1.0.0). The set is keyed by package,
      and the ranged spec wins.
  • meta verify --codegen no longer calls a stale <Entity>.names.ts hand-edited. Rename
    a column in the model and skip meta 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, and meta gen was 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 what meta gen
    recorded writing (.gen-state/.hashes.json) is now reported as ordinary stale output with
    the meta gen remedy. 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.secondary nodes uqKey — legal metadata, and unambiguous per entity, but
    index names are unique per DATABASE, so the first meta migrate in any project that
    opted in died on ERR_DUPLICATE_SQL_NAME before writing a table. They are now
    uqGroupTypeKey, uqGroupKey, uqRoleKey and uqPermissionKey. 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.
    Rename dueDate in the model and a live claim on app::Task.dueDate failed 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.decimal reached through a nested object generated code that did not
    compile.
    ExtractDelegateEmitter.ScalarReader — the reader map for the runtime-DELEGATING
    extract mirror — had no Decimal branch, so a decimal fell through to the DlgString
    default while Fr010FieldMapping.ScalarMirrorType typed the same mirror property decimal?:
    error CS0029: Cannot implicitly convert type 'string' to 'decimal?'. A decimal array
    broke the same way with CS0266 (IReadOnlyList<string?> → IReadOnlyList<decimal?>),
    because the delegating mirror kind-types array elements through ScalarMirrorType too. 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 a DlgDecimal
    helper mirroring ExtractMap.AsDecimal's never-throws contract (an out-of-range double
    degrades to null rather than throwing OverflowException mid-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 in FIELD_SUBTYPES, in both the single and the
    array position, so a newly registered subtype cannot quietly take a mismatched default.

  • C#: the generated AppDbContext failed to compile when an entity name collided with a
    DbSet property name.
    The reference-FK configuration named its column through
    nameof(<Owner>.<Prop>), an expression sitting inside the DbContext class 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 one DbSet per entity. Reachable from stock
    metadata, not just exotic input: Pluralize("Address") == "Addresses", so a model carrying
    both an Address and an Addresses entity emitted nameof(Addresses.AddressId), bound
    Addresses to DbSet<Address>, and failed with CS1061. 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:N UsingEntity<Through>(...)
    sides carried the identical hazard and were converted too.

  • meta init --refresh-docs no 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.ts all 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-docs on 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
    responding template.prompt types its <Template>Response record through
    SpringTypeMapper.javaTypeName, which is richly kind-typed (BigDecimal, LocalDate,
    LocalTime, Instant, UUID, URI, InetAddress, Float, Long for currency) — while
    SpringOutputParserGenerator's mapper recognised only Integer/Long/Double/Boolean and fell
    through to ExtractMap.asString for everything else, with every scalar array going to
    asStringList. Each mismatch is incompatible types at 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.assemble violated 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 through DataConverter, 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 0 instead of null. DataConverter.toBigDecimal
    caught the NumberFormatException and returned BigDecimal.ZERO — the one wrong answer
    available, because 0 is 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 is null, which is the honest one.

  • Java/Kotlin: a declared field.decimal @isArray was silently dropped. DataTypes had no
    DECIMAL_ARRAY, and arrayTypeFor(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. Added DECIMAL_ARRAY plus a toBigDecimalArray that element-converts
    through toBigDecimal rather than a double, so precision survives the round trip. The new
    member takes id 22 rather than a slot beside DECIMAL=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 same metadata module — and
    C#, TypeScript and Python needed no change (that conversion table is JVM-only; TS binds
    field.decimal to string and Python to Decimal).

  • Java: DataConverter.toDate could not parse an ISO-8601 date. The String arm was a bare
    Long.parseLong, so it accepted epoch milliseconds and nothing else — a plain "2026-03-04"
    threw NumberFormatException. Since that is the form normalization.md puts on the wire, no
    field.date or field.timestamp could 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-less HasOne<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 to false and configure those relationships yourself;
    nothing else the generator emits changes. Documented in
    relationships.md.

  • The extract engine's FieldKind vocabulary is now gated across ports
    (scripts/check-extract-field-kinds.mjs, gates lane). Every port's extract engine runs
    fixtures/extract-conformance/, and that corpus's schema.json kind values 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 a Decimal kind 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 own FieldKind — it ships no extract engine, driving the shared
    Java one. Deviations pass only when
    expected-field-kinds.json records
    them with a reason; the C# one is recorded there, including why deleting it is not the
    one-liner it looks like (ExtractMap.AsDecimal has no string arm, so an engine that stopped
    producing decimal would silently return null for every decimal). A recorded deviation that
    is no longer true also fails, so the record cannot go stale.