Skip to content

feat: add brownfield recipe for moving existing models into MetaObjects - #518

Merged
dmealing merged 5 commits into
mainfrom
fm/mo-brownfield-onramp
Oct 31, 2026
Merged

dmealing merged 5 commits into
mainfrom
fm/mo-brownfield-onramp

Conversation

@dmealing

@dmealing dmealing commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

What Changed

Readers with existing Pydantic, Zod or Drizzle models could not see how to move them into a MetaObjects model without a rewrite.

  • Recipe: docs/features/adopting-existing-models.md takes each kind of model from writing the model, through generating beside the existing code and proving the types match, to moving one call site at a time and retiring the old code. It ships in the metaobjects-authoring skill, and a test keeps the two copies identical.
  • Examples: examples/brownfield/{drizzle,zod,pydantic}, each run end to end by brownfield-recipe.test.ts and test_brownfield_recipe.py, including deliberate mismatches that must fail. A new brownfield recipe real-PG check step runs the meta verify --db half in the real-Postgres lanes.
  • Python fix: the proof found that field.string[]: { default: "{}" } generated tags: list[str] = '{}'. It now generates a list. Native YAML list defaults keep working, and a default Python cannot spell is left to the database.
  • Small fixes found on the way: gate_conf_ts now gives the codegen-ts suite the 30s limit the other suites use, because a real compile test timed out at bun's default 5s under load. An unused import is gone from api_docs_builder.py, and the field.enum row in field-types.md now states what Python generates.

No new CLI flag

The recipe needs none. MetaObjects does not read the adopter's source: the language's own tools do the comparison, from a test file the recipe writes and later deletes.

  • TypeScript: tsc over a small Differs type. It compares field by field, because plain mutual assignability passes any and an optional field present on one side.
  • Python: a pytest test comparing model_json_schema() in both modes. mypy cannot do this, because two Pydantic classes are different types whatever their fields are.
  • What tsc cannot see: column names go to meta verify --db, and Zod rules such as .max() go to a z.toJSONSchema comparison.

Before you merge

  • The Pydantic example needs the array-default fix, which is unreleased, so it does not work on PyPI 1.2.0 until the next Python release. This PR does not cut a release.
  • The recipe's limits section lists what the proof cannot see, including native pgEnum columns, Python enum member names and custom validators.
  • The gate had no Postgres, so its tables below list the database check as untested. I ran that case by hand on this head against a real Postgres 16: it passed, including the wrong column that tsc cannot see.
  • This took one no-mistakes run, with two fix rounds: review found a regression for native list defaults and a missing install and generate step, and test hit the timeout above. The optional template check against existing payloads is out of scope.
Validation

Risk Assessment

✅ Low: Docs, examples and a narrow Python array-default fix. The fixer's native-list handling reuses the existing element parsers and refuses nested, null and non-finite shapes, and the mirrored recipe copies are byte-identical to the source.

Testing

Drove the real Python metaobjects gen CLI on a temp copy of examples/brownfield/pydantic. String default "{}" emits list[str] = [], native default [a, b] emits ["a", "b"], and [] emits = []. The equivalence proof passes on the unmodified example and fails on drifted metadata, as intended. The Zod recipe ran through brownfield-recipe.test.ts (CLI run plus tsc); its Drizzle half was skipped for lack of Postgres. Full Python suite with integration extras passed (4193), excluding Docker-backed suites. SDK agent-context mirror tests passed (90). The full Python suite run is a test-suite result, not a live product run, so it is recorded as untested for live purposes.

  • Live validation: ✅ go - 6 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Pydantic brownfield: string-form array default '{}' generates a defaulted list field ✅ pass live metaobjects gen on temp copy of examples/brownfield/pydantic; generated/Order.py tags: list[str] = []
Pydantic brownfield: native YAML list default [a, b] keeps its default ✅ pass live metaobjects gen with default: [a, b]; generated/Order.py tags: list[str] = ["a", "b"] on Order and OrderCreate
Pydantic brownfield: empty native list default [] keeps its default ✅ pass live metaobjects gen with default: []; generated/Order.py tags: list[str] = []
Equivalence proof passes when generated types match hand-written models ✅ pass live pytest proof/test_equivalence.py on unmodified example: 8 passed
Adversarial: equivalence proof fails when metadata drifts from hand-written model ✅ pass live pytest proof/test_equivalence.py after native default changed without updating app/models.py: 2 failed
Zod brownfield recipe: generate beside existing code and tsc proof ✅ pass live brownfield-recipe.test.ts drives CLI run() and tsc on examples/brownfield/zod copy
Drizzle brownfield verify --db proof against live Postgres ⏸️ untested no Needs MIGRATE_TS_PG_URL (ts-slow lane Postgres sidecar); no Postgres provided here and none built, so the database comparison could not run live. brownfield-recipe.test.ts skipped the Drizzle half.
Full regression: Python suite including integration extras ⏸️ untested no Prior payload recorded a pytest suite run (4193 passed), not a live product run; Persistence and api-contract suites are Docker-backed and were excluded by -k, so they were not run.
  • Outcome: 🔧 1 issue found → auto-fixed ✅ across 2 runs (2h9m18s)

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed ✅
  • ⚠️ server/python/src/metaobjects/codegen/runtime/shared_predicates.py:109 - array_default() returns None for any non-string @default, and has_literal_default() then treats the field as having no Python default. Its docstring (line 104) says the metadata value is always a string, but that is false: the loader keeps a native YAML list, because field.json gives @default no fixed subType. Verified by generating examples/brownfield/pydantic with field.string[]: { name: tags, required: true, default: [a, b] }. Base 1d2943f emitted tags: list[str] = ['a', 'b'] on both Order and OrderCreate, which was correct. Head emits tags: list[str] (required, no default) on Order and tags: list[str] | None = None on OrderCreate, with no error or warning. default: [] loses its default the same way. So this fix silently removes a default that worked before and turns a defaulted read field into a required one, and the proof then reports a difference the model looks like it already answers. Fix: in array_default, take a list raw as elements and run it through the same element parsers. Correct the docstring and add native-list cases to test_array_default_is_a_python_list_not_the_metadata_string. Also touches the same contract: entity_model.py _field_line and _create_field_line (both gate on has_literal_default), extractor_generator.py:98 (same predicate), and the CHANGELOG.md Fixed entry and adopting-existing-models.md 'must be a string' row, which should stay true after the fix.
  • ℹ️ docs/features/adopting-existing-models.md:68 - The recipe never states the generate command or the TypeScript install. Step 2 and the 'Generate beside ...' lines show only a config. A grep of the file finds no meta gen, no metaobjects gen and no npm install, although Pydantic gets a pip install line and later steps give npx tsc --noEmit and npx meta verify. An agent following the recipe on a fresh project must work out that it needs @metaobjectsdev/cli (plus @metaobjectsdev/codegen-ts for the Zod config, which imports it directly) and then npx meta gen / metaobjects gen. Add both to each section. The text below '## The recipe' is byte-mirrored, so the same edit must go to agent-context/skills/metaobjects-authoring/references/adopting-existing-models.md and the five fixtures/agent-context-conformance copies. That is why this was not fixed inline.

🔧 Fix applied.
✅ Re-checked - no issues remain.

🔧 **Test** - 1 issue found → auto-fixed ✅
  • 🚨 configured test command failed with exit code 1
  • Live validation: ✅ go - 3 of 4 scenarios driven live against the product
Scenario Result Live Evidence
Python user with a native YAML list array default runs metaobjects gen and keeps the default (adversarial, review-1 regression) ✅ pass live array-default-live.txt: [a, b] gives tags: list[str] = ["a", "b"] on Order and create model; [] gives []; "{}" and "{a,b}" still give [] and ["a", "b"]
Agent follows the brownfield recipe: Drizzle, Zod and Pydantic models are proven equivalent to the generated types and a wrong column is rejected ✅ pass live bun test brownfield-recipe.test.ts 10 pass; pytest test_brownfield_recipe.py passes
Recipe tells the reader how to install and generate, and doc and agent skill reference stay in sync ✅ pass live doc contains the npm install line, npx meta gen and metaobjects gen; diff of recipe text vs skill reference empty; sdk agent-context tests 90 pass
Brownfield real-Postgres meta verify --db check (gate_brownfield_pg) ⏸️ untested no Needs the docker Postgres sidecar that the ts-slow lane starts; this ts-fast/ts-unit step does not start it and I did not start docker. Run scripts/ci-local.sh --only ts-slow to cover it.
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains full run, exit 0 (build+typecheck, TS conformance, TS unit suites, mutation gate score 54.62 vs break threshold 35, about 10 min)
  • cd server/typescript/packages/cli && bun test test/integration/brownfield-recipe.test.ts (10 pass, 1 skip: real-Postgres case)
  • cd server/typescript/packages/sdk && bun test test/agent-context (90 pass, includes recipe-mirrors)
  • cd server/python && uv run pytest tests/codegen/test_brownfield_recipe.py tests/codegen/test_entity_model.py (47 pass)
  • Live: copied examples/brownfield/pydantic to a temp dir, set the tags array default to [a, b], [], "{}", "{a,b}", ran the real metaobjects gen, inspected generated Order.py
  • diff of the '## The recipe' onward text between docs/features/adopting-existing-models.md and the agent-context skill reference (identical); install and gen lines present
  • cd server/python && uv run pytest --continue-on-collection-errors full Python suite: 4024 pass, 3 skip, 1 fail + 18 collection errors, all from optional deps (fastapi, pg8000) missing in the sandbox venv

🔧 Fix applied.
✅ Re-checked - no issues remain.

  • Live validation: ✅ go - 6 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Pydantic brownfield: string-form array default '{}' generates a defaulted list field ✅ pass live metaobjects gen on temp copy of examples/brownfield/pydantic; generated/Order.py tags: list[str] = []
Pydantic brownfield: native YAML list default [a, b] keeps its default ✅ pass live metaobjects gen with default: [a, b]; generated/Order.py tags: list[str] = ["a", "b"] on Order and OrderCreate
Pydantic brownfield: empty native list default [] keeps its default ✅ pass live metaobjects gen with default: []; generated/Order.py tags: list[str] = []
Equivalence proof passes when generated types match hand-written models ✅ pass live pytest proof/test_equivalence.py on unmodified example: 8 passed
Adversarial: equivalence proof fails when metadata drifts from hand-written model ✅ pass live pytest proof/test_equivalence.py after native default changed without updating app/models.py: 2 failed
Zod brownfield recipe: generate beside existing code and tsc proof ✅ pass live brownfield-recipe.test.ts drives CLI run() and tsc on examples/brownfield/zod copy
Drizzle brownfield verify --db proof against live Postgres ⏸️ untested no Needs MIGRATE_TS_PG_URL (ts-slow lane Postgres sidecar); no Postgres provided here and none built, so the database comparison could not run live. brownfield-recipe.test.ts skipped the Drizzle half.
Full regression: Python suite including integration extras ⏸️ untested no Prior payload recorded a pytest suite run (4193 passed), not a live product run; Persistence and api-contract suites are Docker-backed and were excluded by -k, so they were not run.
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • cd server/typescript/packages/cli && bun test test/integration/brownfield-recipe.test.ts (10 pass, 1 skip)
  • cd server/python && uv run --extra dev pytest tests/codegen/test_brownfield_recipe.py tests/codegen/test_entity_model.py (47 passed)
  • Live: temp copy of examples/brownfield/pydantic; uv run --project server/python metaobjects gen with string default, native [a, b], and []
  • Live: pytest proof/test_equivalence.py on unmodified example (8 passed) and drifted metadata (2 failed)
  • cd server/python && uv run --extra integration --extra dev pytest tests/ -n 8 -k 'not persistence and not api_contract' (4193 passed)
  • cd server/typescript/packages/sdk && bun test test/agent-context (90 pass)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Readers with Pydantic, Zod or Drizzle models could not see how to adopt
MetaObjects without a rewrite. docs/features/adopting-existing-models.md
takes each one from writing the model, through generating beside the
existing code and proving the types match, to moving call sites one at a
time and retiring the old code.

The proof is a test file that tsc or pytest runs, so nothing reads the
adopter's source and no new command is needed. TypeScript compares field
by field, because mutual assignability passes `any` and an optional field
on one side. Python compares model_json_schema() in both modes. Column
names and Zod rules are invisible to tsc, so `meta verify --db` and
z.toJSONSchema cover those.

examples/brownfield/ holds a project for each, run end to end with
deliberate mismatches by brownfield-recipe.test.ts and
test_brownfield_recipe.py; the database half joins the real-PG lanes. The
recipe ships in the metaobjects-authoring skill.

The Pydantic proof found a real bug: an array field's default generated
`tags: list[str] = '{}'`. Python now writes a list, from the same four
spellings TypeScript accepts.
Review of the recipe found Python's array default parser wrote nan and
inf into generated source, read JSON null as 'None', and dropped an empty
default for subtypes with no element parser. The Zod rules proof failed
on field order alone and compared only input.
@dmealing
dmealing merged commit cb9a963 into main Oct 31, 2026
15 of 17 checks passed
@dmealing
dmealing deleted the fm/mo-brownfield-onramp branch October 31, 2026 09:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant