All four registries publish: npm 1.0.4 (full lockstep across all 14 @metaobjectsdev/*
publish candidates), Maven Central 8.0.4, PyPI 1.0.4, NuGet 1.0.4 — every port has changed
product code. metamodelVersion stays 1.0.
Upgrading: run meta gen. Every generated file's header comment changed (#367), and
generated routes files gain an auth-seam JSDoc block, so meta verify --codegen reports
drift on any project that commits its generated output until it is regenerated. For most
projects that regen diff is comments only — zero non-comment lines. Two shapes change
for real: a model with two identity.reference nodes onto the same target gets corrected
FK wiring and referential actions (#368 — including an ON DELETE / ON UPDATE diff on
the next meta migrate), and an M:N relationship inherited through extends now derives
navigation that was previously dropped from the output with no error.
Added
-
Libraries: reusable declared design you opt into (FR-043, the sixth pillar).
"libraries": ["iam"]in.metaobjects/config.jsonbrings a shipped, requirement-backed
model into your project. Two ship:iam(preview) — users, nestable typed groups,
roles as permission bundles, grants global or scoped to a group, nine entities and eleven
requirements — andai(stable), the LLM-call trace envelope that already existed.A library is LAYERED, and the core layer is INERT. The core declares no
source.rdb,
and a sourceless object generates nothing and migrates to nothing (#248), so
["iam"]adds zero tables and zero generated code — the design is present and
resolvable, and nothing else happens until you add["iam", "iam/db"]. A layer token
implies its core; a token whose layer is unknown is dropped whole rather than reduced to
it, because answering a mistypediam/databasewith an inert core and no tables is the
worst of the available outcomes.Copy is the expected mode.
meta eject <library>copies every layer into your first
DECLARED source root with a provenance header, andmeta eject --listreports how far
your copy has drifted from the shipped tree — nodes changed, only-upstream, only-yours —
matched by name with the package neutralized, because renaming the package is something
you are invited to do. Ejecting and leaving the library inlibrariesis refused at load
(ERR_LIBRARY_PACKAGE_COLLISION): both trees merge, and the merge is asymmetric —
additions take effect, deletions do not. Its mirrorERR_LIBRARY_PACKAGE_NOT_OWNED
refuses a NEW node declared into a library's package; anoverlay: trueamendment stays
open. See libraries.md.meta gen --listcarrieskind: "library"rows beside the generators — one door, one
namespace — withuseWhen,layers,provides, and under--probewhat your selection
actually added here. -
overlay: truelicenses an attribute override.ERR_MERGE_CONFLICTnow fires only
on an UNMARKED conflicting redeclaration. The flag is the author saying "I know about the
other declaration and I mean to change it", and without this an adopter could not disagree
with a library's shipped requirement without ejecting the whole ledger. All four loaders
(Kotlin inherits the JVM's); one new conformance fixture takes over the unmarked-conflict
error branch, so the coverage moved rather than being deleted. -
layerjoins the cross-port generator manifest —model/persistence/api/
client/docs/capability— gated by all five ports' registry-conformance tests
exactly astieris. Five TypeScript generators that were in no manifest at all (form,
hooks,grid,grid-hook,requirement-tests) join it too, so the catalog describes
34 generators where the manifest described 29. -
meta genaudits the selection. Two self-extinguishing warnings, neither a build
failure: a wired generator whoserequiresare not wired (its output will import a
module nothing emits), and twoapi-layer generators declaring different frameworks
(routes+routes-honoemit to different paths, so nothing conflicts and two complete
HTTP surfaces appear silently). There is deliberately no equivalent rule on theclient
layer:@metaobjectsdev/tanstackpeers onreact, so a form generator plus the TanStack
hook/grid generators is the intended composition. -
The auth seam is printed in the generated routes handler's JSDoc (#367). Stock
CRUD is unauthenticated and no metadata attribute should change that — authentication
is not derivable from a model (ADR-0023). Both frameworks already compose a guard
around the generated mount; only the generated output never said so. Fastify hooks are
encapsulated per plugin scope and inherited by child scopes, so wrapping the call
reaches through the handler's ownregister(..., { prefix }); Hono's trailing wildcard
matches the collection path itself, so oneapp.usecovers list, get and every write.
Both recipes are executed as tests (runtime-ts/test/route-auth-seam.test.ts) rather
than asserted in prose, and each names the generator that emitted the file
(routesFile/routesFileHono) for narrowing withexpose. The JSDoc also states
what no mount can express: a row-ownership rule is not a property of a route, so those
verbs are hand-written and the generated file narrowed around them. -
mountReadOnlyCrudRoutesacceptsrouteOptions(@metaobjectsdev/runtime-ts,
Fastify), the optionmountCrudRoutesandmountM2mRoutealready took. A projection's
generated routes could not carry a route-level hook the way an entity's could — an
inconsistency, not a policy. Read-only is not public.
Changed
-
librariesmoved to.metaobjects/config.json, out ofmetaobjects.config.ts,
outright and with no dual-read — a sweep of the estate found zero uses of the key. Which
designs a project adopts is a fact about the PROJECT, not about how one port generates
code from it. A config still carrying the old key gets a pointed error rather than
silence. -
library/aiis SPLIT intomodel+dblayers, and its requirements are new.
Opting into"ai"alone no longer proposesCREATE TABLE llm_call— that moved to
"ai/db". Breaking-ish for anaiadopter tracking the library: add"ai/db"to keep
the table. The concrete-LlmCallwart was previously carried as accepted on the grounds
that splitting would change what existing adopters get; the estate sweep found there are
none, so it was closed rather than documented. -
trace-helperkeys on a declared ANCHOR, not a hard-coded entity name — and the
name it hard-coded was never actually matching the shipped base. It compared
"LlmCallBase"against the SHORT name, so any adopter entity of that name in any package
emitted a helper writing columns that entity does not declare. It now resolves the anchor
its library's manifest declares and compares by node identity, in all three ports that
ship it. Two self-extinguishing warnings cover the halves of the choice: a library opted
into whose implied generator is not wired, and a generator wired whose library is not. -
Object coverage activates on ADOPTER-authored requirements only. A library shipping
its own ledger would otherwise switch the unclaimed-entity gate on across a project that
has never written a requirement. Library entries are still counted and still checked;
they simply cannot volunteer you.meta verifyprintscoverage: not measured (no project-authored requirements)rather than a ratio, and the JSON omits the pair rather
than zeroing it —0/0 claimedand "not measured" mean opposite things.A project that already has a ledger sees its coverage denominator grow to include an
opted-in library's entities. They are all claimed by the library's own ledger, so no new
warnings appear, but the printed numbers move. -
A shipped library's files carry a stable
library:<ref>.yamlsource id in every
build. It was the file's basename in a checkout andlibrary:…when embedded, so one
node's error envelope read differently depending on how the library was resolved — and
collided with an adopter file of that name. -
Codegen is OPT-IN: no port ships a default generator suite (ADR-0034 Amendment 2).
A new project got code it never asked for — TypeScript'smeta initcopied and wired
five generators, C# ran nine for a caller who named none, Python eight; Java never had
a default set and has been right all along. Deciding which code an application needs
belongs to whoever is building it, increasingly an LLM in the repo, which is well able
to make that call given a truthful catalog and is badly served by a default that
pre-empts it.This is a PATCH and no existing project changes by one byte. An adopter already has
their owned copies on disk and their selection committed in their own config;meta gen
keeps running exactly that list,verify --codegenkeeps checking exactly that output,
and re-runninginitnever clobbers a file that exists. What changes is what a new
project starts with.docs/compatibility-policy.mdis narrowed in the same change: the
scaffold-and-own promise is the LAYOUT and the INTERFACES, not which generators a fresh
scaffold wires.What this means per port:
- TypeScript —
meta initscaffoldscodegen/generators/empty, a config with
generators: [], and no dependencies.dbImportand the throwingsrc/db.tsstub are
gone with it: both existed only because the scaffold wiredroutesFile(), whose output
emitsimport { db } from ….dbImportis now a declaredconfigKeyon theroutes
catalog entry, reported bymeta eject routesto the adopter who chose routes. - C# / Python —
--generatorsis REQUIRED; a run that names none is a usage error
and writes nothing.verify --codegenre-runs the SELECTION, so with none named it
reports that there is nothing to check rather than regenerating a suite the project
never ran. Python'sverifygains--generators, matching C#.
- TypeScript —
-
meta gen --listis now the generator CATALOG, and--probeanswers it against your
own model.--list --format jsonemits one document per generator: itslayer,
framework, what it emits, what itrequires, the consolidated install set, the config
keys it reads, and whether this project already owns a copy.--probeconstructs every
generator and dry-runs it against the loaded model, reporting how many files each would
emit — sooutput-parser: 3, callable: 0, requirement-tests: 7replaces a category
label, and cannot go stale, because it runs the generators rather than describing them. -
meta ejecttakes many names and reports one consolidated install set. A real
selection is several generators, and three separate invocations produced three separate
install lines for the same package.--format jsoncarries, per file, the import line
and the entry to wire, plus one install set with third-party ranges read from the runtime
package's ownpeerDependencies. An unknown name refuses the whole call before writing
anything.
Fixed
-
An M:N relationship inherited through
extendsderived its junction FK columns
against the wrong entity — and in codegen the failure was silent. The derivation
classified the self-join, and matched the junction's source-side
identity.reference, against the entity the CALLER was iterating. Every caller walks
a resolving relationship accessor, so for a relationship declared on a base and
reached through a subclass that is the INHERITING entity, not the one that declared
it. Two failures followed: an inherited self-join compared@objectRef(the base)
against the child, read as hetero, looked for a junction reference to the child and
found none; and an inherited hetero whose junction references the base found nothing
either. TypeScript codegen and the docs-site link graph catch the resulting error and
returnnull, so the navigation was dropped from the generated output with no
error at all; C# codegen did the same; the TypeScript, Java, Kotlin and Python
runtime and codegen paths let it escape, so the traversal or the generation run
failed outright.The declaring entity now comes from the relationship's own parent — the same shape as
#368's loader fix — in all
four derivations (TypeScript, Java, C#, Python; Kotlin calls the Java helper). The
entity being navigated from is kept alongside it rather than discarded: under
inheritance both are legitimate names for the relationship's subject, because a
junction FK usually references the concrete child while@objectRefon a hoisted
self-join names the base. The authoring contract this establishes — the junction FK
may reference either the declaring base or the concrete child, and only those two —
is now written down in
docs/features/relationships.md.C# additionally fixes
M2MNavigation.IsSelfJoin, which had the same confusion one
layer up. It never ran on an inherited self-join before (the derivation threw first),
andDbContextGeneratoruses it to decide whether to emit EFUsingEntitywiring —
so fixing only the derivation would have turned a silent drop into silently wrong EF
configuration.Not a pure widening. One shape that derived before now refuses: a base declaring
@objectRef: <itself>+@throughwith neither@symmetricnor@sourceRefField,
reached through a subclass, used to be misread as hetero and returned an arbitrary FK
direction; it is now correctly recognised as an ambiguous self-join and refused. That
model was already broken — deriving the same relationship from the base itself threw —
so codegen emitted for the child and dropped it for the base. The refusal is the
correct behaviour, but on Java, Kotlin and Python, whose callers do not catch, it
moves from "generates wrongly" to "the generation run fails", and the fix is to add
@symmetricor@sourceRefField.Three narrower resolution changes come with moving the junction matches onto identity,
all three matching what the Java port already did. A junction@references(or an
@objectRef) that is package-qualified must now resolve exactly: a
partially-qualified or stale package no longer falls back to matching the bare tail, so
a reference that used to bind by luck now matches neither the subject nor the
target — both sides are affected, not just the subject side. A bare reference whose
short name exists in more than one package resolves first-declared-wins, which can pick
the wrong-package entity — the pre-existing
#174 behaviour, now reached
by M:N derivation as well. And on Python only, a junction whose@referencesuse
the dottedEntity.fieldform now resolves: that port compared the whole attr value, so
Team.idnever matched the entityTeamand an M:N through such a junction failed
derivation outright. Both junction matches now take the entity head through the same
canonical parse the loader uses, so those models derive where they previously raised.Cross-port divergence goes DOWN, not up. Both junction matches — "does this
reference name the relationship's subject?" and "does this one name the target?" — now
resolve the name to an ENTITY and compare identity in all four derivations, which is
what the Java port already did on both sides. Matching only one side would be worse
than matching neither: the two searches are independent and nothing excludes the
source-side reference from the target search, so a cross-package M:N could bind the
same junction column as BOTH sides and emit(srcFk, srcFk)silently. C#'s
M2MNavigationdescriptor resolves its target the same way for the same reason — its
IsSelfJoinfeeds the EFUsingEntitywiring, and a descriptor that disagreed with
the derivation would mis-map the relationship. TypeScript, C# and Python had been
comparing package-stripped short names, so a genuine cross-package hetero M:N onto a
target whose short name matched the subject's (a::NodeBaserelating tob::NodeBase)
was misread as a self-join on those three — a regression the two-name subject
introduced, caught in review and fixed rather than documented.What changed, exactly: both junction matches in the four derivations, the C# navigation
builder's own target/junction resolution (so the descriptor cannot disagree with the
derivation feeding it), and Python's reference head-parse, which now delegates to the
loader's canonical helper instead of keeping a third copy.@throughresolution and
every comparison outside M:N derivation are untouched, and this is not a general
ADR-0041 sweep — the
resolver added here is deliberately narrow and is not the port's general reference
resolver.No vocabulary change:
metamodelVersionstays1.0and the registry manifest is
untouched. -
Both shipped libraries failed
meta verify's requirement gate, in metadata an
adopter cannot fix: every L4 inaiclaimed FIELDS (ERR_REQUIREMENT_L4_NOT_OBJECT),
and both libraries wrote their concerns as SIBLINGS of the L2 segment their own comments
said they were children of, leaving that L2 claiming nothing in its whole subtree. The
load test proved they LOAD clean, which is a different claim, and nothing checked the
other one. Both ledgers are fixed as the model intends — concerns nested under their L2,
each L4 naming the OBJECT with its fields in an L5 child — and a new standalone gate
holds every shipped library, and every future one, to zero loader errors, zero loader
warnings, zero gate findings, zero lint findings, no unruled gaps, and every entity
claimed by its own ledger. -
Generated files no longer point at a plugin point that does not exist (#367).
Every emitted header carriedCustomize via <Entity>.extra.ts in this directory, and
routes files added(e.g., auth, additional handlers). Nothing in any toolchain imports
a sibling module — the name is a convention, documented as one in
own-your-codegen.md, andEXTRA_SUFFIXwas
deleted fromcodegen-tslong ago for implying otherwise. The cost was measured: an
agent wiring authentication onto a real API read that line on a routes file, went
looking for the seam, found none, and removedroutesFile()from its config rather
than mount five unauthenticated endpoints over a password-hash table. The line now
reads "Extend in your own module (e.g.Order.extra.ts) — nothing imports it for
you.", spelled once insidecarLine(TypeScript) andgenerated_header(Python)
rather than copy-pasted to eleven emitters. -
Two
identity.referencenodes onto the same entity no longer make every
@cardinality: onerelationship join the first one's FK column (#368). An entity
may legitimately declare more than one FK to the same target —Match.homeTeamRef
andMatch.awayTeamRefboth-> Team— but a relationship names only its target via
@objectRef, never which reference it means. Four call sites took the first matching
reference and never noticed the second: the TypeScript codegenrelations()block
wired the relationship to one FK column regardless, the runtime relation traversal
resolved every such navigation through it, a projection's@viajoin hop picked it
even when the hop explicitly named the other reference, and the docs-site link graph
drew the wrong edge. All four produce a join that typechecks, emits correct DDL, and
passesmeta verify— the only symptom is wrong rows. The referential-actions
correlation (TypeScript'smigrate-ts, the C# port, and the JVM tree's Kotlin
Exposed table generator; Python does not implement this correlation) had the same
defect one level over: every FK past the first silently inherited the first
relationship's@onDelete/@onUpdateinstead of its own, so a model mixing
restrictandcascadeacross two references to the same target emitted the wrong
action on whichever FK wasn't examined first. All three are now correlated by
inverting the same ladder, and fail closed rather than guessing.Resolution is now explicit and identical across TypeScript, Python, C# and Java: an
ambiguous@cardinality: onereference set resolves by a ladder — the sole
candidate, else a declared@sourceRefFieldnaming the candidate's FK field, else a
name-pairing match between the relationship's name and a candidate's name/FK field,
elseERR_INVALID_RELATIONSHIPat load, naming every candidate
(ADR-0029
Amendment 1).@sourceRefFieldis now legal on a@cardinality: onerelationship —
it previously failed to load there as an M:N-only attribute. Three limits are
documented rather than fixed here: the ladder matches a candidate's first FK field
only, so two composite references sharing a first column stay indistinguishable (and
resolve to the first, rather than being refused); the loader gate covers
@cardinality: onerelationships only — amany-cardinality relationship, or a bare
identity.referencepair with no relationship wrapper, still reaches codegen
unvalidated; and the gate needs at least one candidate on the holder, so an inverted
shape with both FKs on the far side loads clean and codegen then silently drops the
relation. Seedocs/features/relationships.md.Two consequences worth stating outright. Java's relationship validation changed
from eager-throw-on-first-violation to collect-all-findings —
validateRelationshipsM2Mnow returns aList<MetaDataException>rather than
throwing, and the new rule-(e) pass collects the same way, so a Java loader run
reports every broken relationship where it previously reported only the first; that
is a change to Java loader OUTPUT, not an internal refactor. And adopters with the
affected shapes will see FK referential-action diffs on their nextmeta migrate:
a second FK to the same target now resolves its OWN@onDelete/@onUpdate
instead of inheriting the first relationship's, so the generatedON DELETE/
ON UPDATE(and a Kotlin Exposed table'sReferenceOptionarguments) legitimately
change.No vocabulary was added, removed or retyped — the only change to
expected-registry.jsoncorrects@sourceRefField's own description, which no
longer claims the attribute is M:N-only. Per
docs/RELEASING.md, any change to that file forces all four
registries (npm / PyPI / NuGet / Maven) to publish together at the next release,
changed product files or not — that consequence is recorded here so it isn't a
surprise at release time.metamodelVersionstays1.0.