Skip to content

fix: close 1.1.0-rc.7 re-check decimal and jsonb defects across all five ports - #446

Merged
dmealing merged 6 commits into
mainfrom
fm/mo-1-1-0-rc7-cut
Oct 22, 2026
Merged

dmealing merged 6 commits into
mainfrom
fm/mo-1-1-0-rc7-cut

Conversation

@dmealing

@dmealing dmealing commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

What Changed

  • Decimal and jsonb handling in all five ports. field.decimal inside a jsonb value object now follows the top-level decimal rule: canonical string out, string or number in, exact read, and 400 on excess scale. Decimal arrays are coerced element-wise and keep nulls. C# and Java omit null value-object members on the wire, and the multi-package PATCH is fixed.
    • Python: a fractional JSON number that is not a declared field.decimal stays a float, so a double inside a jsonb value object no longer returns 500.
    • Kotlin: an undeclared-precision decimal gets an unbounded exact Exposed column and no range check. Decimal arrays and value-object decimals are supported.
    • TypeScript: a decimal sent as a JSON number that is not exactly representable is refused with 400 telling the client to send a string. This applies in runtime-ts, the generated decimalWrite helper and ObjectManager. The templates and the example copies are updated to match.
  • Shared api-contract corpus. New scenarios cover decimal arrays, unbounded decimals, and doubles and decimals inside a jsonb value object (decimal-array-exact, decimal-unbounded-exact, decimal-unbounded-exact-15, jsonb-value-object-numbers). The reference and generated servers in each port are updated to pass them.
  • Validator bounds and docs. A fractional validator @min/@max stays an error because the attrs are integral attr.int/attr.long. The ERR_BAD_ATTR_VALUE message now names the edit ("use an integer bound"). The CHANGELOG, 1.0-to-1.1 migration guide, api-contract, RELEASING and CONFORMANCE docs are corrected, and the agent-context skill, templates and conformance fixtures are regenerated.

No version bump, tag or publish. Decimals inside field.map value objects and new metamodel vocabulary are out of scope.

Validation

Risk Assessment

⚠️ Medium: The three fix rounds check out against the code (C# VO-only array nullability, Python map recursion removed, C# JsonException now returns 400 at all three parse sites, independent TS oracle), and I found no new defect; the risk is the size of the change, which touches decimal wire behavior in all five ports plus loader messages, and parts of it I did not read in full.

Testing

Drove decimal and jsonb api-contract lanes live on TypeScript, Python and C# against disposable Postgres/MySQL/SQLite, all passing, plus a new live C# regression test for malformed JSON bodies returning 400 (passes). Not driven: Java and Kotlin live lanes, the full five-port regression suite, and a pre-fix reproduction of the new C# test, so the verdict is inconclusive rather than go.

  • Live validation: ⚠️ inconclusive - 7 of 10 scenarios driven live against the product
Scenario Result Live Evidence
TS: decimal read/write/filter/sort/array/unbounded over HTTP against generated Fastify + Postgres ✅ pass live bun test api-contract-decimal.test.ts (server/typescript/packages/integration-tests)
TS: jsonb value object with numbers and decimals round-trips over HTTP ✅ pass live bun test api-contract-jsonb.test.ts
Python: decimal wire scenarios (incl. decimal-array-exact, unbounded) against generated FastAPI app on Postgres/SQLite/MySQL ✅ pass live pytest tests/integration/test_api_contract_decimal.py (60 passed, 11 skipped)
Python: jsonb value-object numbers and generated decimal wire ✅ pass live pytest test_generated_decimal_wire.py, test_api_contract_jsonb.py
C#: decimal wire, jsonb and map value-object null omission over generated routes on PG/MySQL/SQLite ✅ pass live dotnet test MetaObjects.IntegrationTests --filter Decimal|Jsonb|MapValueObject (40 passed)
C#: malformed or empty JSON POST returns 400 not 500 ✅ pass live new test Malformed_and_empty_body_are_400_not_500_on_sqlite (uncommitted edit to DecimalWireGeneratedApiTests.cs)
Kotlin: unbounded decimal column and decimal arrays / VO decimals ⏸️ untested no Kotlin/Java live lanes (java-slow / integration-tests kotlin) not run in this step due to time; codegen-kotlin unit tests were not run either.
Java: value-object decimal rule and multi-package PATCH fix ⏸️ untested no Java reactor and integration-tests lanes not run in this step.
TS: non-representable JSON number refused with 400 telling client to send a string ✅ pass live covered by api-contract-decimal.test.ts live lane (decimal-write-exact scenarios)
Full five-port regression suite ⏸️ untested no Not run in this step; only the scoped suites above were executed.
  • Outcome: ⚠️ 1 warning across 1 run (1h19m48s)

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ⚠️ server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs:1502 - The new condition (IsDecimalWireField(field) || !withAttributes) && !IsRequired makes EVERY non-required scalar array nullable with no initializer whenever withAttributes is false. That covers value objects (line 838), and also abstract shape classes (line 766). The comment above it says only a non-required decimal array becomes nullable. Example: an abstract BaseTagged { tags: field.string isArray } extended by a concrete Post. Before, the base emitted IList<string> Tags = new List<string>(). Now it emits IList<string>? Tags, and Post inherits that through the C# base chain (lines 282-292). Adopter code new Post().Tags.Add(x) now throws NullReferenceException, and a POST that omits tags stores NULL instead of []. None of this is in the intent, which covers decimal arrays and value-object null omission. Fix: limit the !withAttributes arm to value objects, for example with a flag the VO caller at line 838 passes, so abstract shapes keep = new List<T>(). Also update the stale comment at line 1463, "Arrays are always non-nullable".
  • ⚠️ server/python/src/metaobjects/codegen/generators/router_generator.py:232 - _nested_decimal_spec recurses into a field.map member as if its value were one value-object instance. A map's value is {arbitraryKey: VO}, so decimal_wire._nested compares the map's KEYS against the VO's member names. Example: VO Pricing { tiers: field.map @objectRef Tier }, with Tier { amount: field.decimal }, and body {"pricing": {"tiers": {"amount": {"amount": "1.5"}}}}. The map key amount is treated as the decimal member, so _canonical(dict) raises and a valid body gets 400. For any other key the members fall through to _floats. The intent leaves out "decimals inside field.map value objects", so this recursion exceeds scope and is also wrong in shape. Remedy: remove fc.FIELD_SUBTYPE_MAP from the tuple. A map value then falls through to _floats, which is the deliberately-unsupported behavior.
  • ⚠️ server/typescript/packages/codegen-ts/test/decimal-write-schema.test.ts:57 - The double15 loop decides whether each vector should be accepted or refused with Number.isSafeInteger(n) || Number(n.toPrecision(15)) === n. That is the same predicate the generated decimalWrite helper and decimalFromJsonNumber use. If the threshold were wrong (for example 17 digits instead of 15, or a dropped safe-integer arm), the expectation would move with it and the test would still pass. Use an independent oracle: mark the expected input verdict per vector in fixtures/decimal-wire/vectors.json, or list concrete accepted and refused literals, as the D3 test below already does.
  • ℹ️ server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinDecimalWireTest.kt:265 - The new test a decimal with no precision is an unbounded column and the controller range-checks nothing proves its claim only with substring checks on generated source. Examples: assertFalse("MetaDecimalWire.fits(" in controller) and "portableDecimal(\"price\").nullable()" in table. A dead or renamed check passes the same way, so substring absence does not prove the controller accepts an out-of-range-looking value. The behavior is already covered by executing code: the next test runs the column type, and the Kotlin api-contract lane runs decimal-unbounded-exact. Either drop these assertions or replace them with a compiled-controller call that accepts a 20-integer-digit value.

🔧 Fix applied.
1 warning still open:

  • ⚠️ server/csharp/MetaObjects.Codegen/Generators/RoutesGenerator.cs:773 - The new parseBody: HasDecimalField(entity) path routes every decimal-bearing entity with no @required keys through the hand-parsed POST handler. That handler calls JsonDocument.ParseAsync(http.Request.Body) at line 773 outside the try/catch that only wraps Deserialize. Before this change those entities used the framework-bound ({cls} input, ...) form, where a malformed or empty JSON body gave a bare 400. Now POST /entries with body {not json or an empty body throws an unhandled JsonException, which is a 500. PATCH/PUT at line 318 and TPH at 1196 have the same unguarded parse. Remedy: wrap the ParseAsync call in catch (JsonException) { return Results.BadRequest(new { error = "validation" }); } at 773, and the same at 318 and 1196.

🔧 Fix applied.
2 infos still open:

  • ℹ️ agent-context/skills/metaobjects-codegen/SKILL.md:66 - Docs still tell agents to list owned generators with metaobjects eject --list / dotnet meta eject, which this change's own upgrade guide now says do not exist. The guide (step 6) was corrected to metaobjects gen --list (Python has no eject --list; the eject parser at server/python/src/metaobjects/cli.py:2977 takes only names, --force and --root) and dotnet meta gen --list. The agent-context skill keeps the old wording: "meta eject --list (dotnet meta eject, metaobjects eject, mvn metaobjects:eject) reports each copy that differs". An agent following it in a Python project gets an argparse error. The same line is repeated in the five fixture copies of SKILL.md under fixtures/agent-context-conformance/*/expected/.claude/skills/metaobjects-codegen/SKILL.md, which must be regenerated together with the template.
  • ℹ️ server/python/tests/integration/test_generated_decimal_wire.py:284 - Weak assertion in the new Python test: assert body["fix"] == {"latitude": 1.5, "amount": "100.5", "readings": None} or body["fix"]["amount"] == "100.5". The or arm is implied by the first arm, so the assertion reduces to "amount == '100.5'" and the other members (latitude kept as a JSON number, no spurious members) are never checked. A regression that turned latitude into a string or dropped it would still pass. Assert the exact expected object with one oracle instead.
⚠️ **Test** - 1 warning
  • ⚠️ live validation verdict: inconclusive (7 of 10 scenarios were driven live against the product); untested: Kotlin: unbounded decimal column and decimal arrays / VO decimals, Java: value-object decimal rule and multi-package PATCH fix, Full five-port regression suite
  • Live validation: ⚠️ inconclusive - 7 of 10 scenarios driven live against the product
Scenario Result Live Evidence
TS: decimal read/write/filter/sort/array/unbounded over HTTP against generated Fastify + Postgres ✅ pass live bun test api-contract-decimal.test.ts (server/typescript/packages/integration-tests)
TS: jsonb value object with numbers and decimals round-trips over HTTP ✅ pass live bun test api-contract-jsonb.test.ts
Python: decimal wire scenarios (incl. decimal-array-exact, unbounded) against generated FastAPI app on Postgres/SQLite/MySQL ✅ pass live pytest tests/integration/test_api_contract_decimal.py (60 passed, 11 skipped)
Python: jsonb value-object numbers and generated decimal wire ✅ pass live pytest test_generated_decimal_wire.py, test_api_contract_jsonb.py
C#: decimal wire, jsonb and map value-object null omission over generated routes on PG/MySQL/SQLite ✅ pass live dotnet test MetaObjects.IntegrationTests --filter Decimal|Jsonb|MapValueObject (40 passed)
C#: malformed or empty JSON POST returns 400 not 500 ✅ pass live new test Malformed_and_empty_body_are_400_not_500_on_sqlite (uncommitted edit to DecimalWireGeneratedApiTests.cs)
Kotlin: unbounded decimal column and decimal arrays / VO decimals ⏸️ untested no Kotlin/Java live lanes (java-slow / integration-tests kotlin) not run in this step due to time; codegen-kotlin unit tests were not run either.
Java: value-object decimal rule and multi-package PATCH fix ⏸️ untested no Java reactor and integration-tests lanes not run in this step.
TS: non-representable JSON number refused with 400 telling client to send a string ✅ pass live covered by api-contract-decimal.test.ts live lane (decimal-write-exact scenarios)
Full five-port regression suite ⏸️ untested no Not run in this step; only the scoped suites above were executed.
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains (baseline, already green)
  • bun test test/api-contract-decimal.test.ts test/api-contract-jsonb.test.ts in server/typescript/packages/integration-tests — 43 pass (live Fastify + Postgres via docker)
  • uv run --extra dev pytest tests/integration/test_api_contract_decimal.py tests/integration/test_generated_decimal_wire.py tests/integration/test_api_contract_jsonb.py in server/python — 60 passed, 11 skipped (live generated FastAPI app, postgres/sqlite/mysql)
  • dotnet test MetaObjects.IntegrationTests --filter Decimal|Jsonb|MapValueObject in server/csharp — 40 passed (live generated routes, testcontainers PG/MySQL)
  • Added Malformed_and_empty_body_are_400_not_500_on_sqlite in server/csharp/MetaObjects.IntegrationTests/Api/DecimalWireGeneratedApiTests.cs — POST {not json and empty body return 400 validation; passes (live HTTP against generated routes, sqlite)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

Validation: this change took 1 no-mistakes validation run (CI step skipped; full scripts/ci-local.sh green on the validated head). The test step was approved after the Kotlin, Java and five-port suites were run locally. Known gap: decimals inside field.map value objects are not covered.

parse_json_object reads every non-integer number as a Decimal; only declared
decimal fields should keep it. A field.double or a number inside a jsonb value
object or array stayed a Decimal and json.dumps failed, answering 500 on
create, update and PATCH.
- Python: decimal array element-wise writes, jsonb value-object decimals and
  floats, exact normalization beyond 28 digits
- Kotlin: unbounded exact Exposed column for undeclared-precision decimals,
  decimal arrays, value-object decimal range check, Exposed 1.x array bind
- TypeScript: refuse JSON-number decimals not exactly representable; decimals
  inside jsonb value objects follow the top-level rule; validator reports 400
- C#: decimal array nullable, value-object decimal converter, validation
  envelope, multi-package PATCH fix
- Java: OMDB decimal arrays on Postgres; value-object nulls omitted
- Corpus: decimal array / unbounded decimal / jsonb value-object numbers
  scenarios wired into every port
- Fractional integral attr bound error names the edit
- Docs, CHANGELOG, agent-context fixtures, RELEASING updates
@dmealing
dmealing merged commit 53452ae into main Oct 22, 2026
1 check passed
@dmealing
dmealing deleted the fm/mo-1-1-0-rc7-cut branch October 22, 2026 14:42
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