Skip to content

fix: make JSON wire format a project setting, defaulting to each language's standard - #457

Merged
dmealing merged 4 commits into
mainfrom
fm/mo-wire-format-setting
Oct 24, 2026
Merged

dmealing merged 4 commits into
mainfrom
fm/mo-wire-format-setting

Conversation

@dmealing

@dmealing dmealing commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Fixes #454

What this changes

1.1.0 put every port's generated API on one JSON form, with no way back. Decimals became canonical strings (12.50 was sent as "12.5"). C#, Java and Kotlin stopped writing a value object's null members. A C# adopter moving from 1.0.4 to 1.1.1 had to strip attributes by regex and delete DecimalWire.g.cs to get their API back.

The output form is now a project setting, wire in .metaobjects/config.json. Every port's codegen reads it the way it reads naming, so there is no CLI flag and no generator argument (ADR-0019 Amendment 2):

{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } }
  • The default for both keys is native: what that language's standard serializer writes.
  • The 1.1.0 form is opt-in: { "decimals": "string", "nullMembers": "omit" }. The conformance corpora pin it in their own config. They never decide a default.
  • Input is unchanged in every mode: a string or a JSON number is accepted and read exactly, and garbage or a value past @scale/@precision is 400 validation. All five ports keep the generated input converters.
  • Generated code carries output converters and annotations only when the setting asks for them.
  • Drift naming: every port's codegen drift gate (meta verify --codegen, dotnet meta verify --codegen, mvn metaobjects:verify, metaobjects verify) says which wire block reproduces committed output generated under another setting. It no longer says "regenerate", because regenerating would change the API's JSON.
  • The governing rule is in ADR-0019 Amendment 2 and is one new bullet under CLAUDE.md "Design judgment": each port defaults to its own language's standard behaviour, and identical cross-port output is opt-in.

Also fixed: in C#, when the model's packages bind to more than one namespace, DecimalWire.g.cs and MetaNetValidation.g.cs are now named per namespace (DecimalWire.<Namespace>.g.cs). Before, gen refused the run with a duplicate output path.

The grid (each cell checked against real serializer output from generated code)

Port decimals: native (default) decimals: string nullMembers: native (default) emit omit Change from 1.1.1 with no setting
C# JSON number, scale kept (12.50, 0.0000001) "12.5" null, key present same as native left out decimals are numbers again and null members are written again (the 1.0.x output)
Java JSON number, as the host's Jackson writes a BigDecimal (12.50, 1E-7) "12.5" null, key present same as native left out the same: the 1.0.x output
Kotlin same as Java "12.5" null, key present same as native left out the same: the 1.0.x output
Python string, as Pydantic writes a Decimal ("12.50", "1E-7") "12.5" as stored every declared member, null when unset left out decimals keep their scale ("12.50", not "12.5")
TypeScript string as the driver returns it ("12.50"; 15 significant digits on SQLite and D1) "12.5" as stored every declared member, null when unset left out decimals keep their scale ("12.50", not "12.5")

There is deliberately no "number" value. In C#, Java and Kotlin, native already is a number. In Python and TypeScript, a number would be a lossy spelling that neither language uses.

Two decisions the maintainer made

  • TypeScript and Python native now write "12.50", where 1.1.1 wrote "12.5". That is what their standard serializers write. Projects that want the 1.1.x string declare "decimals": "string".
  • Python 1.0.x routers returned plain dicts, and FastAPI's encoder turned a Decimal into a float (12.5, losing digits past a double's precision). Native is now Pydantic's str(Decimal). No setting restores the float, because it loses digits. The upgrade guide says so.

Docs and skills

  • docs/features/api-contract.md has a new section, "The wire setting", with the full grid.
  • docs/ports/{csharp,java,kotlin,python,typescript}.md are updated.
  • The 1.0-to-1.1 upgrade guide says to go to 1.1.2 or later. Its C# generated-text rows now match the native default.
  • In the agent-context skills:
    • There is a new metaobjects-codegen/references/wire-format.md.
    • A section in SKILL.md and a per-port section in each language reference cover the setting. They tell the agent never to strip the attributes or delete DecimalWire.g.cs, but to set wire and regenerate.
  • The CHANGELOG [1.1.2] section, under Fixed, has an entry for each fix.
  • There is a new shared corpus, fixtures/wire-conformance/, which every port runs.

Tests

The full scripts/ci-local.sh passed locally. I also ran these locally:

Suite Result
C# Codegen and CLI green
Python 4160 passed
Java metadata, codegen-spring, maven-plugin, codegen-kotlin 2872, 398, 257+3, 559 passed
Java and Kotlin integration lanes on Testcontainers green
TypeScript sdk, metadata, runtime-ts, codegen-ts, cli green
TypeScript integration 832 passed

On the pipeline head, the Python full suite including Testcontainers integration passed 4161, and C# Conformance, Codegen and CLI passed 2078, 1007 and 309.

The no-mistakes gate

The gate took one run. Review found four issues, and the pipeline fixed all four in one commit:

  • Python M:N routes now shape the target's value objects.
  • C# FromDirectory now applies wire.
  • Python null-member shaping no longer recurses into nested JSON.
  • The skill now states that Python 1.0.x wrote a float.

Test passed with an approved exception. Its live verdict was inconclusive (TypeScript was driven live; C#, Java, Kotlin and Python were not), and the maintainer's supervisor approved it on the suite results above. The 1.1.2 release reruns the full ci-local on main before publishing.

No version bump; this does not cut a release.

Validation

Risk Assessment

✅ Low: The four round-1 fixes are correct and covered by behavioural tests. I re-read only the fix-round commit, not the full branch diff.

Testing

Drove the TypeScript meta gen CLI live against disposable projects in /tmp/wire-e2e. An invalid wire value is refused by name with exit 2. Portable config (decimals: string, nullMembers: omit) emits decimalWire/nullMembers options on the generated Order routes. Default config emits none. The runtime wire mapper, given those generated options, behaves as specified: null VO member omitted, decimal canonicalized, native keeps "12.50" and the null member. TS wire-conformance test passes (18). Python wire conformance and m2m shape tests pass (15), but only as unit/conformance runs, not against a live app. Python and TS live generation of the M:N target-shape fix and the C# FromDirectory fix were not exercised live. Full regression suite not run here.

  • Live validation: ⚠️ inconclusive - 5 of 10 scenarios driven live against the product
Scenario Result Live Evidence
TS: invalid wire value in .metaobjects/config.json is refused by name at gen time ✅ pass live meta gen --dry-run output: wire.nullMembers must be one of native, emit, omit; exit 2
TS: portable wire setting reaches generated routes ✅ pass live meta gen in /tmp/wire-e2e/ts; src/generated/Order.routes.ts options block
TS: default (no wire) generates native routes with no wire options ✅ pass live meta gen in /tmp/wire-e2e/native; no decimalWire/nullMembers lines
TS: portable mapper omits null VO member and canonicalizes decimal; native keeps both as stored ✅ pass live bun /tmp/wire-e2e/drive/ts-wire.ts output
TS: emit keeps declared VO members present as null ✅ pass live bun /tmp/wire-e2e/drive/ts-wire.ts emit line
Python: wire conformance and m2m shape tests ⏸️ untested no Only unit/conformance pytest run (15 passed); no generated FastAPI router was run against a live app, so no live result is established.
Python: m2m target shape honours nullMembers on the target value object ⏸️ untested no Did not generate a Python project with a target VO and run the M:N route live; time budget.
C#: FromDirectory applies wire setting to Root.WireFormat ⏸️ untested no Did not run the C# test suite or dotnet build in this phase.
Java/Kotlin: native and portable serializer output ⏸️ untested no Not driven in this phase.
Full regression suite ⏸️ untested no Full regression not run in this phase; only the baseline ci-local ts-fast/ts-unit command was run.
Evidence: TS generated portable routes (Order.routes.ts)
decimalColumns: ["total"], decimalWire: "string", nullMembers: "omit", valueObjects: { price: { members: ["amount", "note"] } }
Evidence: runtime wire mapper output
portable {"id":1,"total":"12.5","price":{"amount":"3.00"}}
native {"id":1,"total":"12.50","price":{"amount":"3.00","note":null}}
emit {"id":2,"total":"1","price":{"amount":"5.00","note":null}}
  • Outcome: ⚠️ 1 warning across 1 run (9m12s)

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 4 issues found → auto-fixed ✅
  • ⚠️ server/python/src/metaobjects/codegen/generators/router_generator.py:880 - Python M:N traversal routes write the TARGET's rows through the SOURCE entity's _WIRE constant, so nullMembers: emit|omit does not apply to the target's value-object columns. _wire_lines([entity], wire) (line 1538) builds shapes from the source entity's fields only. _emit_m2m_route (line 880) and _emit_tph_m2m_route (line 937) still return render_wire(repo.find_related_&lt;rel&gt;(...), _WIRE). Concrete case: Post M:N Tag, Tag has a jsonb field.object meta of value object TagMeta {label, note}, project sets &#34;wire&#34;: {&#34;nullMembers&#34;: &#34;omit&#34;}. GET /posts/1/tags writes &#34;meta&#34;: {&#34;label&#34;: &#34;x&#34;, &#34;note&#34;: null}; the TS port omits note because its m2m mount gets decimalColumnsLine(entry.target, ...). Under emit, a stored member that is missing stays absent. When the source and target both declare a value-object field with the same name but different shapes, the target value is shaped with the source's members (emit adds wrong keys). Decimals are not affected, because render_wire converts every Decimal by type. Fix: emit one WireOutput per relation built from object_index[d.target_entity] (plus its TPH subtypes when relevant), and pass that to the m2m render_wire calls in both emitters.
  • ⚠️ server/csharp/MetaObjects/Config/MetadataLocation.cs:46 - C# MetaDataLoader.FromDirectory applies the project's naming from .metaobjects/config.json (MetaObjects/Loader/MetaDataLoader.cs:158, added by fix(csharp): scope generators with Include and apply project naming on directory load #456 for hosts that compose their own registry) but not the new wire setting. The sibling ports do: Java fromDirectory/fromUris call setWireFormat(discoverWireFormat(...)) (MetaDataLoader.java:727, 815), and Python _apply_discovered_naming calls attach_wire (meta_data_loader.py). Only ResolvedMetadata.Load and the CLI set Root.WireFormat. Concrete case: a C# host does MetaDataLoader.FromDirectory(dir, registry, strict: true) and runs EntityGenerator with wire: {decimals: string, nullMembers: omit} declared. It silently emits [DecimalWire.Number] and no JsonIgnore, which is the native output, while that same load honours naming. Fix: set result.Root.WireFormat = cfg.Wire beside the PhysicalNaming assignment at MetaDataLoader.cs:158 (one line).
  • ℹ️ server/python/src/metaobjects/codegen/runtime/decimal_wire.py:313 - _shape_members recurses into EVERY non-shape dict value of a row (else _shape_members(v, wire)), so a nested key that happens to equal a value-object column name gets shaped. Concrete case: an entity with value-object column address and an untyped jsonb/map column extras holding {&#34;address&#34;: {...}}. Under emit, extras.address gains null-filled Address members. TS nullMembersWire rewrites only the top-level columns it was given. render_wire is always called on rows or a list of rows, never on the envelope, so the nested recursion is not needed. Fix: leave non-shape values untouched (else v).
  • ℹ️ agent-context/skills/metaobjects-codegen/references/wire-format.md:79 - The upgrade bullet says that from 1.0.x, "In Python and TypeScript, a decimal keeps its scale (&test(conformance): combined multi-package extends + overlay + cross-package references fixture #34;12.50&test(conformance): combined multi-package extends + overlay + cross-package references fixture #34;)". This implies Python already wrote a string. Python 1.0.x wrote a lossy float (12.5), which the CHANGELOG and upgrade guide state plainly (maintainer decision 2). An agent reading only the skill would tell a Python 1.0.x adopter that nothing changes for clients. Fix: add the Python float-to-string note to this bullet. Not fixed inline because the text is mirrored byte-for-byte into the five fixtures/agent-context-conformance expected copies, which must be updated together.

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

⚠️ **Test** - 1 warning
  • ⚠️ live validation verdict: inconclusive (5 of 10 scenarios were driven live against the product); untested: Python: wire conformance and m2m shape tests, Python: m2m target shape honours nullMembers on the target value object, C#: FromDirectory applies wire setting to Root.WireFormat, Java/Kotlin: native and portable serializer output, Full regression suite
  • Live validation: ⚠️ inconclusive - 5 of 10 scenarios driven live against the product
Scenario Result Live Evidence
TS: invalid wire value in .metaobjects/config.json is refused by name at gen time ✅ pass live meta gen --dry-run output: wire.nullMembers must be one of native, emit, omit; exit 2
TS: portable wire setting reaches generated routes ✅ pass live meta gen in /tmp/wire-e2e/ts; src/generated/Order.routes.ts options block
TS: default (no wire) generates native routes with no wire options ✅ pass live meta gen in /tmp/wire-e2e/native; no decimalWire/nullMembers lines
TS: portable mapper omits null VO member and canonicalizes decimal; native keeps both as stored ✅ pass live bun /tmp/wire-e2e/drive/ts-wire.ts output
TS: emit keeps declared VO members present as null ✅ pass live bun /tmp/wire-e2e/drive/ts-wire.ts emit line
Python: wire conformance and m2m shape tests ⏸️ untested no Only unit/conformance pytest run (15 passed); no generated FastAPI router was run against a live app, so no live result is established.
Python: m2m target shape honours nullMembers on the target value object ⏸️ untested no Did not generate a Python project with a target VO and run the M:N route live; time budget.
C#: FromDirectory applies wire setting to Root.WireFormat ⏸️ untested no Did not run the C# test suite or dotnet build in this phase.
Java/Kotlin: native and portable serializer output ⏸️ untested no Not driven in this phase.
Full regression suite ⏸️ untested no Full regression not run in this phase; only the baseline ci-local ts-fast/ts-unit command was run.
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • node server/typescript/packages/cli/dist/bin/meta.js init (in /tmp/wire-e2e/ts)
  • meta gen --dry-run with wire.decimals=canonical -> refused, exit 2
  • meta gen with wire {decimals:string, nullMembers:omit} -> src/generated/Order.routes.ts carries decimalWire/nullMembers
  • meta gen with no wire block -> routes carry no wire options (native)
  • meta gen with wire {nullMembers:skip} -> refused, exit 2
  • bun /tmp/wire-e2e/drive/ts-wire.ts (withWire from runtime-ts with generated options)
  • PYTHONPATH=server/python/src python3 -m pytest tests/conformance/test_wire_conformance.py + wire/m2m tests -> 15 passed (unit/conformance, not live)
  • bun test packages/sdk/test/wire-conformance.test.ts (server/typescript) -> 18 pass
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…nguage's standard (#454)

1.1.0 normalized every port's generated API onto one wire form with no opt-out:
decimals as canonical strings and value-object null members omitted. That broke
live C#, Java and Kotlin clients on a 1.x upgrade.

The output form is now `wire` in .metaobjects/config.json, read by every port's
codegen the way `naming` is (ADR-0019 Amendment 2). Both keys default to native,
the language's standard serializer output: C#/Java/Kotlin decimals are JSON
numbers again and null members are written; Python and TypeScript keep the
decimal's scale. `{"decimals": "string", "nullMembers": "omit"}` restores the
1.1.0 form, and the conformance corpora pin it in their own config. Input rules
are unchanged in every mode. Every port's codegen drift gate names the wire
block that reproduces committed output generated under another setting.

Also fixes the C# DecimalWire.g.cs / MetaNetValidation.g.cs collision when a
model binds more than one namespace.

Fixes #454
@dmealing
dmealing merged commit 1e24430 into main Oct 24, 2026
1 check passed
@dmealing
dmealing deleted the fm/mo-wire-format-setting branch October 24, 2026 18:26
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.1 changed the C# decimal and VO-null wire format by default with no opt-out; make it a setting and emit null for VO members

1 participant