Skip to content

0.24.3 / 7.24.3 — the gate that convicted the innocent

Choose a tag to compare

@dmealing dmealing released this 28 Aug 03:33
· 684 commits to main since this release

A coordinated PATCH across all four registries (npm 0.24.3 · PyPI 0.24.3 · NuGet 0.24.3 ·
Maven 7.24.3), full lockstep across all 14 @metaobjectsdev/* publish candidates. Every fix in
it is TypeScript-side
cli, codegen-ts, and repo tooling — so PyPI, NuGet and Maven Central
are version-parity bumps with no changed product file. metamodelVersion stays 0.13: no
registered vocabulary changed.

The theme is a gate that convicted the innocent. Three of the five fixes are a check failing
work the product itself sanctions — the documented quickstart's own npx tsc, the hand edits
three-way merge exists to preserve, and a model whose generators never import a db singleton.
Two were found by running the documented quickstart cold against the published 0.24.2, which is
now part of the release procedure rather than a thing done at a cut.

Fixed — dbImport was demanded from the model, and blocked an upgrade nobody saw fail

#194 made dialect and dbImport optional config, then required both from any model
declaring a source.rdb. dialect belongs there: every sourced object is lowered to SQL, so a
wrong default is silently wrong everywhere. dbImport does not. Only a generator emitting
import { db } from … can read it, and a project whose generated queries take db as an
explicit parameter — the shape the scaffolded queriesFile() emits — has no singleton to name.
Such a project was refused, and told its model "generates database code for: …" that in fact
imported nothing.

What that cost, measured on the public reference app. Its automated dependency PR
(0.20.110.21.6) had been failing since 15 August, twelve days, on exactly this:

meta: verify --codegen: regeneration failed: codegen config is missing dbImport —
  required because this model generates database code for: CouncilTurn, Council.
##[error]Process completed with exit code 1

The remedy is one config line, which no bot can write — so the app sat four minor lines behind
with a red PR nobody reads, while every human-facing signal said it was healthy. When the
upgrade was finally done by hand it took that one line and two generated deltas.

dbImport is now demanded at the point of use: reading it is what proves a generator emits
the import, so reading is what asks for it, and the runner's existing [<generator>] wrapper
names which one. A generator that never touches it never demands it. Declaration is tracked,
never inferred by comparing against the default — meta init scaffolds a relative path and a
project may legitimately write the default's own string. Verified against the reference app:
published 0.24.2 fails, this build emits all 29 files byte-identically.

Fixed — meta verify --codegen convicted files MetaObjects never wrote

Found by re-running the documented TypeScript quickstart cold against published 0.24.2, as a
pre-launch check. Every documented step passes — meta init, meta gen, npx tsc clean,
meta migrate --from-db … --apply creates the table, the generated Fastify server serves all five
verbs, an empty @required string is a 400 — and then the drift gate exits 1 on a project with
no drift of any kind
, naming sixteen files:

npx tsc                             # the quickstart's own instruction
meta verify --codegen               - src/generated/Author.js  (committed but regen would not emit it)
                                    - src/generated/Author.d.ts.map (…)   … 16 in total   exit 1

A stock tsc --init config sets no outDir, so the compiler writes .js / .d.ts / .map
beside the sources it compiled — into the generated directory. The gate's orphan branch fired on
every file in outDir that a fresh regen would not produce, whether or not MetaObjects had
ever written it.

The rule is jurisdiction, not staleness. outDir is a directory, not a namespace MetaObjects
owns. "A regen no longer emits this path" means stale generated output only for a path we have a
record of WRITING; for anything else it means the file was never ours. .gen-state/.hashes.json
already records exactly that, and meta gen's own orphan sweep already scopes itself to those
paths (listGeneratedPaths) before it will delete anything — so the write path and the gate were
answering the same ownership question two different ways. They now agree.

Nothing is given up on the case the branch exists for: a file MetaObjects wrote that a regen no
longer emits — an entity deleted or renamed — is still in the manifest, so it is still drift and
still exits 1. With no manifest at all there is no evidence to tell our stale output from a
stranger's file, so the old conservative verdict stands, matching the fail-closed default the
content branch uses. Both halves are pinned by tests.

Blast radius was every project that compiles in place and runs --codegen in CI — and, because
the quickstart tells you to run npx tsc, every project that follows it exactly. Sibling of the
hand-edit conflation below: same gate, the other branch.

Fixed — meta verify --codegen convicted the hand edits the product tells you to make

meta gen three-way-merges a hand edit into generated output and reports merged. That is the
documented contract — "anything inside a generated file is fair game to hand-edit; three-way
merge preserves it."
verify --codegen then failed the same file, and the remedy it printed
could not work
: running meta gen merges the edit back in, so the next run failed identically.
Reproduced on a clean external install:

meta gen                            src/generated/Bot.ts, merged
meta verify --codegen               ~ src/generated/Bot.ts (committed content differs …)
                                    Run 'meta gen' to regenerate, then commit the result.   exit 1
meta gen && meta verify --codegen   exit 1

computeCodegenDrift byte-compared committed output against a fresh regen, and its own header
admitted the conflation — "either 'metadata changed but meta gen wasn't re-run' or 'a generated
file was hand-edited'."
Only the first is drift.

The evidence to tell them apart already existed and was already committed.
.gen-state/.hashes.json records what the GENERATOR WROTE rather than what the file became, and
is the committed half of .gen-state precisely so the question is answerable on a machine that
did not generate the output. The gate now asks the question it can honestly answer — is the
generated contribution current?
— by comparing a fresh regen's hash against the recorded one. It
fails closed: no recorded hash, no proof, old verdict.

The two structural branches are untouched (an orphaned committed file and an uncommitted new one
are both still drift), and genuine staleness still exits 1 and still names the file. What this
gives up, stated: a hand edit that contradicts the metadata is no longer caught here — but it
never could be told apart from a legitimate one, so the gate failed both, and a gate that fails
the sanctioned workflow gets switched off. The compiler and the test suite keep hand-written logic
honest; verify keeps the generated contribution honest.

Blast radius was every project that hand-edits generated output and runs --codegen in CI, worst
for requirementTests() stubs, which are worthless until hand-edited because the assertion is the
author's to write. Design: spec/design-docs/2026-08-27-codegen-drift-hand-edits-design.md.

Fixed — the pre-release pin detector passed when the violation was large

scan() piped grep into head -20 under set -o pipefail. Once the output exceeds the ~64KB
pipe buffer, head closes the pipe, grep dies of SIGPIPE, the pipeline reports that failure, and
|| return 0 returns before the hit is recorded. The severity was inverted: a small breach
failed the check, a large one passed it silently. Independently reproduced against a synthetic
500-violation tree — the old script exits 0, the fixed one exits 1.

The full result is now captured and decided on, with truncation only for display and a line naming
what was withheld. This matters beyond this repo: the script's header instructs adopters to copy it
into every downstream consumer that participates in pre-release testing, so any copy taken before
this carries the same inverted severity and should be re-copied.

Fixed — bun run release ran the private-registry publisher instead of the release

npm and bun both run pre<name> before <name>, with no per-script opt-out. The root
manifest declared both release and prerelease, so bun run release <version> — the
command docs/RELEASING.md and the release skill both name — silently invoked
scripts/prerelease.mjs first and never reached scripts/release.mjs. The 0.24.2 cut hit
it and published through bun scripts/release.mjs directly.

It failed loudly only by accident. The hook died on absent MO_REGISTRY_* credentials;
with tools/prerelease/registry.env present it would have SUCCEEDED, publishing a
private-registry iteration as an invisible side effect of every public release.

The private-registry publisher is now bun run prerelease:publish (same script, same
flags). bun run release is unchanged and now reaches the release.

A gate ships with the fix, because the collision is a property of the two NAMES rather than
of either script: scripts/check-script-name-hooks.mjs fails when any root script is
pre<x>/post<x> for another root script, wired into ci-local.sh's gates lane.