0.24.5 / 7.24.5 — codegen ownership is the framework story
All four registries publish, and not one of them is a version-parity bump — each carries a
changed product file of its own (npm: FR-040 + the eight-defect batch; PyPI/NuGet/Maven:
columnNamingStrategy, @column, the Maven <sourceDir> fix, the C# hash-manifest anchor, and
the agent-context staleness nudge in every port). That the first release under publish-what-changed
publishes everything is a coincidence of what landed, not the rule reasserting itself.
metamodelVersion stays 0.13: no registered vocabulary changed.
A registry now publishes only when it changed. The version-parity rule standing since
0.20.13 is retired, and this is the first release cut under its replacement — so the four
registries may legitimately carry different numbers from here on.
Changed — publish what changed; converge the number when you do
docs/RELEASING.md contradicted itself five lines apart. It mandated "every release bumps
all four registries, with version-parity bumps where a port has no changed file" and then stated
that the conformance corpus + CAPABILITIES.json — "not a shared version" — is the coordination
point. Both cannot be the rule. If the shared version is not what carries the cross-language
guarantee, publishing byte-identical content to three registries to keep it aligned buys nothing;
the phrase "version-parity bump" appears ten times in this file paying for it. 0.24.3 and
0.24.4 were each a single changed file in cli, and each became a four-registry event.
The new rule: a registry publishes only when it has a changed product file, and when it does it
adopts the current shared minor.patch, skipping the numbers it sat out. Two carve-outs, because
only ONE lockstep relaxes — the 14 npm packages still move atomically with each other (they
cross-depend), and a change to expected-registry.json / metamodelVersion still forces all
four, because that is the cross-port contract every port byte-matches.
A lagging version becomes information: PyPI at 0.24.4 while npm is at 0.24.7 says PyPI has had
no product change since 0.24.4. Under parity that was unreadable, because every registry carried
the same number whether or not anything in it had moved.
scripts/release-verify.mjs gains --registries=npm,pypi,nuget,maven so a cut verifies only what
it published; without it the script reports a red ✗ for a port behaving exactly as the rule
requires, and a gate that fails on correct behaviour is one people learn to run with their eyes
closed. The default stays all four — forgetting the flag over-checks rather than under-checks — and
a scoped run names the registries it did not check on every invocation, because a partial run
printing only ✓s reads exactly like a full one.
Fixed — a context newer than the install is not stale (all five ports)
The rule above broke the agent-context staleness nudge, in every port, and the fix ships in the
same release. A port now legitimately sits behind npm — while meta agent-docs, the canonical
scaffolder for every port, stamps the npm version it ran from. So a Python install at 0.24.4
whose context was scaffolded by npm 0.24.7 is correct, and every port nudged it; running the
suggested remedy re-stamps 0.24.7, so the advisory could never be satisfied and fired on every
build forever. That is #347 exactly,
reborn in four ports at once — the JVM through stalenessAcrossVersionLines, and TS / C# / Python
through plain equality on their own 0.x line. One bug, three different routes.
A context stamped by a strictly newer release is no longer treated as stale. The suppression is
deliberately narrow because it contradicts a documented decision — all three non-JVM ports carried
"Don't 'fix' this into a semver compare", written when install and scaffolder always matched.
That property is preserved exactly: anything not orderable as a plain N.N.N still nudges —
prereleases, build metadata, non-numeric versions, and the 0.0.0 unresolved-install sentinel,
which must never assert "in sync".
Two bounds, stated rather than hidden. Ordering on minor.patch assumes both versions sit in
the same release series; across the one-time 1.0/8.0 cut a 0.24.x context against a 1.0.0
install reads as "ahead" and the nudge is suppressed once — a missed advisory, never a wrong action.
And the opposite failure is narrowed, not closed: equal coordinates still assert in-sync, so a
port parked at 24.4 across several npm releases is not told its context has moved. Settling that
needs the shipped context hashed rather than its version compared, and the JVM ships no
agent-context content — only the manifest reader.
Added — FR-040: codegen ownership is the framework story
MetaObjects does not need a codegen package per framework. It needs the ownership doctrine
it already publishes to be true all the way down — and in three places it was not. A codegen
library cannot chase frameworks: there are more of them than any library can carry, they turn
over faster than a release line, and each one added is a permanent liability the metamodel —
the actual durable asset — gains nothing from. The shipped agent context already says the
right thing ("treat this as a first-class, expected activity — not an escape hatch"). This is
the release that makes it true. The bar is not "Next.js works": it is that an agent adopting
onto a stack nobody wrote a recipe for — Svelte, Nuxt, Qwik — reaches a working generator
unaided, and treats having done so as normal.
How it was found, and the framing error worth recording. A cold adoption probe ran the
documented TypeScript quickstart against published 0.24.4 inside a Next.js 16 / React 19 app
on Turbopack with a real Postgres. Its substantive result was positive and is not in
dispute: the schema and server tiers work on that stack unmodified — queriesFile() takes
db as a parameter, so a Server Component calls it with no HTTP hop; routesFileHono() is
deps-injected, the shape an App Router Route Handler wants; the full cross-port API contract
held. The probe then reported eight "findings", and most were mis-framed as defects. The
default templates target Fastify on Node; that they do not emit Next.js output is the
templates doing what they say. Recording that error is the point — a report that reads a
template mismatch as a product failure will keep proposing framework packages forever. Three
findings survived re-scoring, and they are what shipped.
-
meta eject <generator>— the ownership move is now a command. ADR-0034 hadmeta init
eagerly copy four generators (entity,queries,routes,barrel) into
codegen/generators/and own them. The other five could be used but never owned: there
was no supported way to get one's source, at init time or after.meta ejectis that same
copy operation generalised to every ejectable name in every package, callable at any time —
for a generator you skipped at init, or one a package gained since.meta eject --list
names all nine grouped by package (entity,queries,routes,routes-hono,barrel
fromcodegen-ts;formfromcodegen-ts-react;hooks,grid,grid-hookfrom
codegen-ts-tanstack). It never clobbers without--force, matchinginit's rule, and it
reports the import line to paste by parsing it out of the template's own header rather
than re-deriving it from the file name — a generator's exported symbol does not follow its
file name (hooks.tsexportstanstackQuery,routes-hono.tsexportsroutesFileHono),
so a derived map would drift from what the template already tells a human to paste. -
The UI tier became ownable at all. It was the gap with no workaround: an RSC app needs
"use client"at the top of generated form/hook files, and there was no seam to add it —
the choice was use the package's output verbatim or hand-write the tier and leave metadata
behind. Four new reference templates (form,hooks,grid,grid-hook) plus a new
routes-hono, and the render layer is promoted to public API —renderFormFile,
renderHooksFile,renderColumnsFile,renderGridHookFile, each a stable
(entity, ctx) => string— so an owned generator composes the engine and replaces only the
step its framework disagrees about, instead of copying ~600 lines of package internals out.
codegen-tsalready exportedrenderRoutesFile; this applies the same pattern to the UI
packages. The reference-template reader is now a per-package factory, so a package that
gains templates later is picked up bymeta ejectwith no other change. -
Every reference template documents its own swap point. A
targets:header block on all
nine states what the template targets, when to use it, what it emits, where to change it,
and what it composes with — so an agent retargeting reads the seam out of the file rather
than inferring it. Paired with a new "Your framework isn't the default — the retargeting
procedure" section in themetaobjects-codegenskill, which is the general answer the
per-framework recipes were standing in for. -
clientDirective— the"use client"knob. Set it and the four generated client
artifacts (form, hooks, columns, grid-hook) get the directive React Server Components
frameworks require; leave it off (the default) and output is byte-identical to before.
It is config, never a metadata attribute: the directive is a fact about the
adopter's bundler topology, not about the entity, and registering it would give every
non-TS port vocabulary it can never dispatch on — thesource.rdb @rolemistake that
retired four members in0.21.0. It defaults off because the directive is only
required under RSC and is inert-but-warned-about elsewhere; the asymmetry that would
argue for defaulting on (a runtime error for RSC adopters versus a build warning for
everyone else) is precisely what the rest of this release removes — before it, an RSC
adopter had no seam at all.<Entity>.meta.tsis deliberately excluded: it is plain
data imported by a client component, and in RSC the boundary is the importing
component, not everything it reaches.
Docs stop promising what the project does not intend to ship. AGENTS.md and the port
docs described a first-party package per framework as the way to reach a new framework; that
was never the plan and is now stated as the ownership move it actually is. The agent-facing
quickstart in docs/llms/ had been teaching the deprecated @metaobjectsdev/codegen-ts/generators
import path, and the ownership one-liner samples in the docs did not type-check — both fixed,
with the samples now compiled.
meta init scaffold honesty. Four fixes where the scaffold said something untrue about
its own output, the same class the 0.24.4 line was cut for: it stopped eagerly scaffolding
an unwired routes-hono.ts (a file nothing imported, presented as if it were live); it now
scaffolds a throwing src/db.ts stub, so the module the default routesFile() imports
exists and fails with an instruction rather than TS2307; it names the .gen-state manifest
and explains dbImport instead of leaving both as unexplained config; and its fastify
devDependency is aligned to runtime-ts's peer range, which it contradicted. Separately, the
TanStack Table v8 requirement is now discoverable — @metaobjectsdev/tanstack bounds the
peer at v8, but nothing told an adopter installing @tanstack/react-table themselves.
One recipe, explicitly a convenience. docs/recipes/nextjs-vercel.md
walks the Next.js App Router + Vercel path — the extStyle: "none" / clientDirective: true
config delta, routesFileHono() mounted at app/api/[[...route]]/route.ts via hono/vercel,
and generated query helpers called straight from a Server Component. It changes no package
file, and it opens by saying the general procedure lives in the metaobjects-codegen skill:
it is a shortcut past reasoning an agent could do unaided, which is the only relationship
FR-040 permits it to have. Two of its notes exist because the failure is SILENT — a Server
Component reading the database is not a dynamic signal, so the page prerenders at build and
serves build-time rows forever while looking correct in next dev; and apiPrefix is baked
into the emitted route path, so a Hono basePath on top double-prefixes it.
Design: docs/superpowers/specs/2026-08-29-fr-040-framework-agnostic-codegen-ownership-design.md.
Amends ADR-0034.
Review round. Five of the fixes above are review findings on FR-040's own first
draft, and one is worth naming because it is the shape this project keeps convicting
itself of: the five new templates shipped with no equivalence gate. ADR-0034 makes a
copyable template safe by running it and the built-in it was copied from over a fixture
corpus and requiring byte-identical output — and that gate covered only the four
meta init scaffolds. Since src/reference is excluded from tsconfig, the five new ones
were imported by nothing, executed by nothing and type-checked by nothing: a renamed
engine export, or a drifted filter deciding WHICH entities emit, would have reached an
adopter running meta eject before it reached a red lane. The tsconfig comment even
asserted the coverage, having been copy-pasted into the two UI packages from the one
where it was true. Every template is now gated in every package, the file SET is
compared as well as the contents (a drifted filter changes what is emitted, not how), and
each gate asserts its own coverage equals REFERENCE_GENERATOR_NAMES so the tenth
template cannot repeat this. Also fixed from that round: meta eject told you to paste
an import that collides with the package import already in the documented config — whose
quiet failure mode is a config that keeps running the PACKAGED generator while you edit
the ejected file — and never named the dependency the ejected file imports, so the
adopter's own tsc reported TS2307 on the file the CLI had just said they owned.
Second review round. Seven more findings, and the two worth naming share a shape with
the first round's: a change that was RIGHT drew its line one notch too wide, and nothing
could see the difference. The retargeting split took four ports' own*() guidance with
it. Moving meta eject and the metaobjects.config.ts keys out of the port-agnostic
SKILL.md was correct — a Python project runs metaobjects gen and has no eject command,
so those adopters' agents were being handed a procedure their toolchain cannot execute.
But the same move carried off the ADR-0039 section, whose per-port own↔resolving table
is port-agnostic by construction: its entire content is the OTHER ports' accessor names,
including the trap that TS attr() resolves while Python attr() is own. It landed in
references/typescript.md, the one page a Java, Python or C# adopter never installs,
while metaobjects-authoring still told them to go read it there. And SKILL.md closed
by sending every reader to "this skill's references/ fragment for your server language",
which for four of the five contains no retargeting content at all — a pointer to nothing,
where before the split there had at least been a procedure (a wrong one, which is what the
split fixed). The three port-agnostic sections are back in SKILL.md, and the closing
pointer now says what is true per port, including that the other ports have no eject
command and owning a generator there means implementing that port's generator interface.
meta init claimed a stub it had just decided not to write. Its next-steps block was
one static string describing the scaffolded src/db.ts in the imperative, printed on every
run — including the meta init --force in a project keeping its own config, where the
write is deliberately skipped. Worse was the silence beside it: wroteScaffoldedConfig is
only "no config existed", so it is false for the config init itself wrote one command
earlier, and a scaffolded project whose src/db.ts is deleted lands in the same branch as
a project that owns its config — nothing written, nothing preserved, nothing said, and
the next tsc reporting TS2307 on a file the command had just chosen not to restore. It
warns now rather than writing, because dropping a file into a project that owns its config
is the unilateral host-project touch FR-040 §4.4 lists as a defect.
Also from the round: meta eject stated which import a project currently has, which
it never checks and which is wrong for exactly the four meta init scaffolds — the
--force re-sync case — so it names the goal and the three branches instead; its
dependency notes could not see a subpath import and reported a peer-declared package as
missing, advice that if followed adds the competing physical copy this repo has been
bitten by twice; and nothing gated clientDirective on the generated form, the one
client artifact the RSC story centres on. That last one is the round's own theme again,
and the proof is sharper than the finding: with the directive dropped from a reference
template alone — the two halves genuinely different — reference-byte-identical stays
green, because it generates with the knob off, where the call is a no-op and removing
it changes no byte it compares. The equivalence gate cannot see that defect in either
direction. Both UI packages now run the knob against the built-in and the reference
template, and each package's template coverage is a Record keyed by ejectable name whose
keys are the proof and whose values are the wiring — so a tenth template is a compile
error, not a hand-maintained list that can be edited to claim a gate nobody wrote.
Fixed — eight defects found by adopting the product from scratch, twice
Two adoption runs against the published 0.24.4, docs followed literally and nothing fixed
mid-run: a greenfield app built with MetaObjects, and a realistic hand-written app
(Drizzle + Zod + Fastify + string-concatenated prompts, a populated three-table database)
migrated onto it. What the two runs proved out is worth stating first — the drift gate found
a real pre-existing bug in the hand-written app the moment its prompt was declared
(a book.status reference surviving a rename to shelf_state, invisible to tsc), the
filter layer was correct throughout, and swapping hand-written routes for generated ones
preserved the wire shape exactly. Everything below is what went wrong on the way.
- BLOCKER — adopting an existing SQLite database emitted a migration the tool could not
apply. Any rebuilt table that another populated table references failed with
SQLITE_CONSTRAINT_FOREIGNKEY: FOREIGN KEY constraint failed— which is most real
schemas, on the scaffold's default dialect, along the documented adoption path. The
0.21.4fix was present and on this path, rewriting the file'sPRAGMA foreign_keys = OFF
toPRAGMA defer_foreign_keys = ONbecause the former is a no-op inside a transaction.
Deferral is not a substitute for this recipe:DROP TABLE's implicit delete records a
deferred violation per referencing row, the repair isALTER TABLE … RENAME TO— a rename,
never an insert — so the counter never decrements and COMMIT fails. Proven with a
controlled pair on identical database copies. The pragma is now issued outside the
transaction, which is SQLite's own documented procedure, and restored in afinally;
PRAGMA foreign_key_checkis lifted out to run after commit, where its rows are no longer
discarded. Every existing rebuild test was blind to this because they all rebuild a table
nothing references. - The schema-snapshot gate's printed remedy was a no-op. It named
meta migrate --from-db, which writes a snapshot only when it has changes to emit — so on
a database already matching the metadata it reported "nothing to do", wrote nothing, and
the gate failed again identically. The user follows the instruction, is told everything is
in sync, and is stuck in a loop. It now namesmeta migrate baseline --from-db, and the
new test parses the command out of the message, runs it, and requires the gate to pass —
so any future edit naming a command that does not repair fails, whatever it says. - The strict response parser validated 3 of 11 field subtypes.
enum,uuid,date,
time,timestamp,decimal,currency,uriandinetall fell through to
z.unknown(), which accepts anything — includingnullon a@requiredfield. The map
carriedclass,shortandbyte, the three subtypes this project cut as non-functional
stubs, and missedcurrencyanduuid. This inverted the pillar: validation was strongest
on the payload we control and absent on the reply we do not. It was an internal
contradiction, not a gap — the tolerant extractor in the same generated file rejects a
non-member, and so does Python'sFieldSpec.enum_field; only the path namedparse<Name>
and documented@throws on validation failurethrew the domain away. - A declared
template.promptwith no prompt generator wired emitted nothing and said
nothing, whilemeta verifyreported the template "clean". The payload value objects
are emitted (they areobject.valuenodes), so the run looks like it worked.meta gen
now warns, naming the templates and distinguishing the missing send side from the missing
receive side — self-extinguishing, following thelayout.dataGridprecedent (#287). - A constraint violation returned 500 with the SQL and its bound parameter values in the
response body. Wrong status for a client error, and on a POST carrying PII or a token
that is user data reflected to an unauthenticated caller out of generated code. Now a 409
({"error":"constraint_violation","constraint":"foreign_key"}) with no query text;
unrecognised driver errors are logged in full and rethrown redacted. Classification walks
thecausechain, because Drizzle wraps every driver error in aDrizzleQueryErrorwhose
own message is the query — reading only the top level matched nothing at all, which the
first cut of this fix did until it was run against a live server. - Three generators spelled the same template three ways —
renderTriageTicket,
parsetriageTicket,type triageTicketData— because one applied a privatepascal()and
two concatenated the raw name. A singletemplateSymbolBase()innaming.tsnow owns it.
Adopter-visible: a template whose name begins lower-case (the spelling the prompts
skill's own examples use) gets renamed generated symbols; an UpperCamel name is unchanged. - The prompts reference's config example moved you off the owned generators, importing
entityFile/queriesFile/barrelfrom the ADR-0034-deprecated package path. meta initclaimed the manifest "declared no module system" whennpm init -ywrites
"type": "commonjs"explicitly — false on the dominant first-touch path. (Introduced by
0.24.4's own fix to that same line, which corrected the tense and got the premise wrong.)
Fixed — @column is the physical column name, and four ports could not choose one
A field has two names — the one your code calls it and the one the database calls
it — and @column sets the second. The byte-gated registry prose has always said the
first comes "via columnNamingStrategy". Four of five ports could not set that
strategy, one port used @column for the wrong name entirely, and one ignored it.
- Python's read model renamed itself to
@column.<Entity>'s Pydantic field was
@column or field.namewhile its own<Entity>Create/<Entity>Patch, its
generated router (which stampsdto["createdAt"]) and its runtime all key by
field.name—ObjectManagerbuilds{_column_of(f): f.name}on every read and
RETURNING clause, commented "for cross-port row-shape parity". So one generated
module disagreed with itself, and the read model's stated reason ("the Python model
field IS the column, so it binds straight to the row") was never true of that
runtime. It also leaked a free-form name:callPurpose+@column: purpose_code
emitted a fieldpurpose_code, neither the wire name nor derived from the field
name. The read model now keys byfield.name, like every other surface in every
other port. Idiomatic snake_case for a Python consumer is a real goal and its lever
is the strategy below, applied tofield.name. - Kotlin's Exposed generator ignored
@columnoutright, hardcoding
camelToSnake(field.name)at every column site — so a field declaring one bound the
WRONG column at runtime, silently, with no error anywhere and no escape hatch. It
also made Kotlin the only port hardcoding snake_case while Java'sgetColumnRef
resolved literal: one model, two column names, one JVM. Both now go through a
sharedcom.metaobjects.database.ColumnNaming. columnNamingStrategyis now selectable in the four ports that lacked it —
dotnet meta gen --column-naming, PythonGenConfig(column_naming=…)/
ObjectManager(…, column_naming=…), JavaSimpleMappingHandlerDB.setColumnNaming(…),
Kotlin's<columnNaming>generator arg. No default moves (TS/Kotlin-codegen
snake_case, C#/Python/Javaliteral): a default that moved would silently
re-point live queries at columns that do not exist. An unknown value is refused
rather than falling back, because a typo would otherwise bind a whole schema to the
wrong columns and report success. Why it matters: schema is Node-owned (ADR-0015)
andmeta migratedefaults tosnake_case, so a C# or Python adopter with a
multi-word field name generated data access against a column the migration never
created — and until now could only fix it by declaring@columnon every field.
Documented, with the per-port default table, indocs/features/field-types.md.- The persistence corpus could not see any of it, and now can. Its canonical schema
is pinned toliteraland no fixture carried a@column, so every field's column
name equalled its field name and nothing could tell a port that resolves@column
from one that ignores it.Program.createdAtnow declares@column: created_ts—
deliberately NOT the snake_case of the field name, which is what a snake_case
strategy would produce anyway and would have proven nothing. Adding it turned up
three live defects at once: the Kotlin generator above, plus the Kotlin and C#
scenario runners both keying result rows by physical column instead of field name.
All five ports' lanes pass against the de-blinded corpus.
Fixed — two ports scattering or skipping work while reporting success (Maven, NuGet)
Found the same way as the eight above, by a different exercise: generating ONE small model
with every port, from the repo root, to build the corpus the website publishes as real
meta gen output. Both are the shape this release keeps finding — a tool saying something
untrue about work it had just done.
- Java/Kotlin:
<loader><sourceDir>with no<sources>loaded NOTHING, ran the
generators against an empty model, wrote zero files, and reportedBUILD SUCCESS.
The precedence ladder consults the port-neutral config only when the pom names neither
key — naming either means the pom owns the concern — so<sourceDir>alone took the
pom-owns-it branch with an empty source list. That is the shape the shipped adopter
guidance teaches, and it is also the remedy0.24.0's ownERR_COLLECTION_NOT_FOUND
prints ("declare<sourceDir>/<sources>explicitly"), so following the fix for a
silently-empty model put you back into one. A directory now expands through the SAME
DirectorySourcewalk the loader itself uses for a directory source, so "which files
count as metadata" keeps one definition; a<sourceDir>that exists but holds no
metadata isERR_COLLECTION_NOT_FOUNDrather than an empty model, since reaching the
expensive outcome by a different road is still the expensive outcome. A nonexistent
<sourceDir>already failed and still does. Purely additive — a pom naming
<sources>, or naming neither, is unaffected. - C#:
dotnet meta genanchored.gen-state/.hashes.jsonon the process working
directory, so generating from anywhere but the project root wrote a stray
.metaobjects/into the caller's cwd and left the real project with no record of what
was written — the entire reason that manifest is committed. The Python port already
anchors on the metadata dir's parent and its docstring says why. The C# test assembly
had been doing this into its ownbin/for as long as the manifest has existed,
invisible becausebin/is ignored, and worse than untidy: every test in the assembly
then shared ONE manifest keyed by bare filename — precisely the collision that docstring
warns about. An explicit<metadataDir>now anchors on its parent; with it omitted the
.metaobjects/config.jsonladder has already resolved relative to cwd, so cwd is the
project. Not a behaviour change for the documented invocation — only the
cwd-is-not-the-project case moves, and that case was broken.
Fixed — a retired requirement's generated test stub failed forever, then told you to revive it
The requirement-test renderer decides skip-versus-fail from a set of statuses, and that set
was a literal the vocabulary moved out from under. 0.24.0 retired abandoned and
superseded; 0.24.2 put retired in their place. The set was never moved across, so it
skipped two statuses the loader had begun REFUSING and failed on the one that replaced them:
every @status: retired entry emitted a stub asserting expect.unreachable forever,
reddening an application's suite for a capability nobody intends to rebuild. That is the noise
a suite gets silenced wholesale for — taking the live stubs with it — and it is the exact
outcome the set exists to prevent, stated in its own comment.
The set is now derived from the loader's own enum: a status that is neither live nor
partial is skipped by construction, so the next status move cannot leave it behind.
Restating a closed set next to the closed set is what produced this.
The second half was found by fixing the first. The two skipped statuses mean opposite
things and were sharing one body — "Intended, not built. Write the assertion when this becomes
live." On a retired entry that instructs the reader to revive the capability, inverting
the one guardrail 0.24.2 restored retired for. A retired stub now states that the capability
was deliberately removed and must not be rebuilt, and that anything asserted there should assert
it STAYS removed.
Found by authoring the first real retired entries outside a conformance fixture. Every
existing test in the file passed throughout, because none of them used a status the vocabulary
had changed. TypeScript-only — the requirement-test renderer ships in codegen-ts and has
no counterpart in the other four ports.