Skip to content

fix(metadata): repair temporal recursion, gson wiring, and array writes in Java serializer - #277

Merged
dmealing merged 10 commits into
mainfrom
fix/serializer-date-recursion-and-json-docs
Aug 8, 2026
Merged

fix(metadata): repair temporal recursion, gson wiring, and array writes in Java serializer#277
dmealing merged 10 commits into
mainfrom
fix/serializer-date-recursion-and-json-docs

Conversation

@dmealing

@dmealing dmealing commented Aug 8, 2026

Copy link
Copy Markdown
Member

Intent

Fix GitHub issue #275 (a latent StackOverflowError in the Java object-JSON serialization layer) and close issue #273 (a documentation gap that was deliberately gated on #275 being fixed first). Branch fix/serializer-date-recursion-and-json-docs, 9 commits, base 25f3a0f.

THE BUG: MetaObjectSerializer's "case DATE:" passed context.serialize() the CONTAINING object instead of the field value. Because the serializer is registered against that object's own class, it re-dispatched to itself infinitely -- StackOverflowError (an Error, so uncatchable by the usual catch(Exception) around a best-effort write) on every field.date/field.timestamp write, including when the value was null, since the branch never read the field at all. The blast radius was wider than the issue stated: TimestampField is also DataTypes.DATE, and OMDB's typed-jsonb codec serializes through this same serializer, so a field.object @storage:jsonb value-object carrying any temporal field crashed an OMDB INSERT/UPDATE. It never fired in the conformance corpus only because the one jsonb fixture happens to have no temporal field.

FOUR UNITS:

(A) The DATE fix, plus a new shared TemporalWireFormat helper defining the ISO-8601 wire form -- deliberately matching the existing cross-port serialization contract in fixtures/persistence-conformance/normalization.md rather than inventing one (field.date -> YYYY-MM-DD; field.timestamp @localTime -> naive wall clock, no Z; field.timestamp default -> UTC instant with Z; millisecond fraction with trailing zeros stripped and omitted when zero). Both readers get a DATE/LONG split: a JSON number still means legacy epoch millis, and that backward compatibility is precisely what makes this release a PATCH rather than a MINOR; a JSON string is tolerantly parsed through three ISO forms. Also deletes a locale-dependent setDefaultDateFormat()/DateFormat.FULL call, which was evidence the branch had never been finished.

(B) Two Gson adapter-wiring bugs, fixed atomically in one commit because they masked each other: JsonObjectReader registered serializers where it needed deserializers, and MetaObjectGsonInitializer's addSerializer/addDeserializer flags were commented out at both class-registration sites so both kinds registered regardless. Fixing the flags alone would have left the reader with no deserializer.

(C) The serializer's write side now honors mf.isArrayType(), so an array-valued field writes a real JSON array instead of the comma-joined string "a,b" -- silent round-trip corruption. Deliberately bounded: this unit was scoped to MetaObjectSerializer.writeField only, and that bound held (3 files, zero changes to MetaField or DataConverter).

(D) The #273 documentation gap: five files now document this layer as the sanctioned serialization path, which was only honest to recommend once (A) made it safe. Includes a deliberately sanctioned fix to a drift-test regex that falsely flagged any dotted Java FQN segment (com.metaobjects.io.object.json) as a bogus metamodel subtype -- fixing the root cause rather than mutilating the docs around it, after an earlier attempt to work around it left an uncompilable snippet.

Plus two standalone docs commits: an unrelated temporal/identity/timezone research document, and the plan's own STATUS flip.

DELIBERATE CARRY-FORWARDS (not defects, not oversights -- these were consciously left, and are recorded in the plan file): MetaField.setObject converts via the field's scalar getDataType() instead of the array-aware getEffectiveDataType(); DataConverter has no DATE_ARRAY implementation; and the OMDB jsonb temporal path still has no regression test. Unit C's bounded-scope clause names the first two almost verbatim as scope-creep triggers. Consequence, verified and accepted: the array WRITE side is fixed while array STORAGE remains broken. This is coherent because the read half already threw at baseline, so nothing regresses -- the change converts silent write corruption into correct output and leaves two unreachable-but-correct code paths that become live when DATE_ARRAY lands.

CONSTRAINTS HONORED: no new metamodel vocabulary, attributes, or error codes anywhere; ADR-0039 resolving accessors only (never own*()); public-repo hygiene (no private project names, no absolute home paths in any file or commit message).

ALREADY GATED before this run: every unit independently reviewed; a final whole-branch review returning 0 Critical and 3 Important, all fixed and re-reviewed clean -- including a genuinely serious catch, a locale-dependent String.format("%03d") that under a non-Latin-digit default locale would write Eastern Arabic-Indic digits into the wire form, producing output this layer's own reader could not parse back and that would land silently in OMDB jsonb columns; 25 deferred findings triaged with rulings and 1 parked; two free-text sweep passes over both code and docs; and a full local CI run, 16/16 green including five-port conformance, the Java reactor, and the docker integration suite.

DO NOT publish, tag, or cut a release. The release (a coordinated PATCH across four registries) is a separate maintainer checkpoint that has not happened yet.

What Changed

  • MetaObjectSerializer no longer passes its containing object back to context.serialize() on field.date/field.timestamp (the StackOverflowError); a new TemporalWireFormat helper owns the ISO-8601 wire form, and readers split DATE from LONG so a JSON number keeps meaning legacy epoch-millis while a JSON string parses through ISO forms.
  • Two Gson adapter-wiring bugs fixed atomically — JsonObjectReader now registers deserializers instead of serializers, and the previously-commented addSerializer/addDeserializer flags in MetaObjectGsonInitializer are restored — and the serializer's write side honors isArrayType() so array fields emit real JSON arrays rather than comma-joined strings.
  • Documents the object-JSON layer as the sanctioned serialization path (closes Docs: no guidance on serializing MetaObjectAware / PojoObject instances — consumers hand-roll Jackson workarounds #273), adds a temporal/identity/timezone research note, and tightens the agent-context drift regex so dotted Java FQNs aren't mistaken for bogus metamodel subtypes.

Risk Assessment

✅ Low: A focused, correct serialization-layer bug-fix: the only live behaviors move from a StackOverflowError / silent array corruption to correct output, with comprehensive tests, structural byte-identity for untouched scalar paths, and three explicitly-authorized non-regressing carry-forwards all verified accurate in source.

Testing

Reproduced the #275 StackOverflowError at base through the actual public JsonObjectWriter.writeObject entry point, then confirmed the fix emits the documented ISO wire form (field.date→YYYY-MM-DD; field.timestamp→…Z; @localTime→naive) and round-trips via JsonObjectReader.read; all 29 new JUnit pins (DATE recursion + wire format, the two Gson adapter-wiring bugs, the isArrayType write side, and the Locale.ROOT fraction catch) pass, the Unit D drift-test regex fix passes 4/4, and the working tree was left clean.

Evidence: RED — pre-fix StackOverflowError at base (real public API)

java.lang.StackOverflowError at ...MetaObjectSerializer.writeObject(MetaObjectSerializer.java:53) at ...MetaObjectSerializer.serialize(MetaObjectSerializer.java:44) at ...MetaObjectSerializer.writeField(MetaObjectSerializer.java:77) <-- context.serialize(vo), the containing object at ...MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54) at ...MetaObjectSerializer.serialize(MetaObjectSerializer.java:44) ... (infinite recurse) Generated by overlaying base 25f3a0f8 MetaObjectSerializer.java (case DATE: jsonObject.add(name, context.serialize(vo))) over the fixed tree and calling JsonObjectWriter.writeObject(orangeWithDate).

-------------------------------------------------------------------------------
Test set: com.metaobjects.io.object.gson.E2E275EvidenceTest
-------------------------------------------------------------------------------
Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.405 s <<< FAILURE! -- in com.metaobjects.io.object.gson.E2E275EvidenceTest
com.metaobjects.io.object.gson.E2E275EvidenceTest.realPublicApi_dateField_roundTrips_andProducesIsoDateWire -- Time elapsed: 0.377 s <<< ERROR!
java.lang.StackOverflowError
	at java.base/java.lang.String.contains(String.java:2970)
	at com.metaobjects.cache.HybridCache.useIdentityCache(HybridCache.java:91)
	at com.metaobjects.cache.HybridCache.get(HybridCache.java:131)
	at com.metaobjects.MetaData.getCacheValue(MetaData.java:1859)
	at com.metaobjects.MetaData.useFrozenCache(MetaData.java:1923)
	at com.metaobjects.MetaData.getChildren(MetaData.java:1411)
	at com.metaobjects.object.MetaObject.getMetaFields(MetaObject.java:375)
	at com.metaobjects.object.MetaObject.getMetaFields(MetaObject.java:368)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:53)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdap

... [63931 bytes truncated] ...

s.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeObject(MetaObjectSerializer.java:54)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.serialize(MetaObjectSerializer.java:44)
	at com.google.gson.internal.bind.TreeTypeAdapter.write(TreeTypeAdapter.java:108)
	at com.google.gson.Gson.toJson(Gson.java:943)
	at com.google.gson.Gson.toJsonTree(Gson.java:801)
	at com.google.gson.Gson.toJsonTree(Gson.java:778)
	at com.google.gson.internal.bind.TreeTypeAdapter$GsonContextImpl.serialize(TreeTypeAdapter.java:195)
	at com.metaobjects.io.object.gson.MetaObjectSerializer.writeField(MetaObjectSerializer.java:77)
Evidence: GREEN — fixed real-API write: Orange with pickedDate

{"@type":"simple::fruitbasket::Orange","pickedDate":"2026-06-03","id":1,"name":"orange"}

{"@type":"simple::fruitbasket::Orange","pickedDate":"2026-06-03","id":1,"name":"orange"}
Evidence: GREEN — fixed real-API write: null pickedDate (pre-fix this also recursed)

{"@type":"simple::fruitbasket::Orange","id":2,"name":"orange-no-date"}

{"@type":"simple::fruitbasket::Orange","id":2,"name":"orange-no-date"}
Evidence: GREEN — round-trip read-back of the date field

pickedDate read back as midnight UTC of 2026-06-03 = epochMillis 1780444800000 (field.date wire form is calendar-date-only by the cross-port normalization.md contract; 14:30:00.123 writes as 2026-06-03 and reads back at 00:00 UTC — designed behavior, not data loss)

pickedDate read back as midnight UTC of 2026-06-03 = epochMillis 1780444800000

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • mvn -o -pl metadata test -Dtest='GsonTemporalRoundTripTest,GsonArrayWriteRoundTripTest,GsonWiringBugsTest' (13+11+5 = 29 tests, the three new pins for Units A/B/C + the Locale.ROOT fraction catch)
  • E2E275EvidenceTest: real public API JsonObjectWriter.writeObject(Orange with pickedDate=2026-06-03T14:30:00.123Z) + JsonObjectReader.read() — produces "pickedDate":"2026-06-03", round-trips to midnight UTC, null date does not crash (GREEN, post-fix)
  • RED contrast: overlaid base 25f3a0f8 MetaObjectSerializer.java over the fixed tree, ran the same real-API write → java.lang.StackOverflowError (serialize→writeObject→writeField line 77 context.serialize(vo) → recurse); restored fixed file, git diff vs HEAD empty
  • bun test packages/sdk/test/agent-context/drift.test.ts (Unit D: negative-lookbehind regex so dotted Java FQNs like com.metaobjects.io.object.json aren't falsely flagged as bogus metamodel subtypes) — 4 pass after one-time bun install
  • mvn -o -pl metadata test -Dtest='GsonTemporalRoundTripTest,GsonArrayWriteRoundTripTest,GsonWiringBugsTest,E2E275EvidenceTest' on restored tree — 31/31 green
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

dmealing and others added 10 commits August 8, 2026 12:03
…stamp (#275)

MetaObjectSerializer.writeField's `case DATE:` handed the CONTAINING object
back to context.serialize(vo) instead of extracting the field value.
MetaObjectGsonInitializer registers the serializer against the VO's own
class, so that call re-dispatched to the same serializer for the same
instance -- unbounded recursion, StackOverflowError, on every
field.date/field.timestamp write (even a null one, since the branch never
read the field). TimestampField is also DataTypes.DATE, so field.timestamp
crashed too, and OMDB's typed-jsonb codec routes VO columns through this
same serializer, so any field.object @storage:jsonb VO with a date/timestamp
field crashed an INSERT/UPDATE.

Adds TemporalWireFormat (shared by the Gson serializer/deserializer and the
streaming JsonObjectReader) implementing the wire-form contract from
fixtures/persistence-conformance/normalization.md: field.date writes the
UTC calendar date ("YYYY-MM-DD"); field.timestamp writes the UTC instant
("YYYY-MM-DDTHH:MM:SS[.fff]Z"), or the naive wall clock with no Z when
@localTime is set. On read, DATE splits from LONG's shared branch: a JSON
number is still the legacy epoch-millis form (PATCH-compatible -- nothing
that parsed before stops parsing), a string is a tolerant ISO parse (Z /
no-Z / date-only). JsonObjectWriter's `setDefaultDateFormat()` call (Gson's
locale-dependent DateFormat.FULL, evidence this path was never finished) is
removed as superseded; the method itself stays for its other callers.

Pinned by a new GsonTemporalRoundTripTest: the crash (set and null-valued),
every fraction-rounding vector, legacy epoch read, tolerant ISO read on all
three wire forms plus a clear error on a garbage string, a write-read-write
round trip, and a byte-identical no-churn pin for a VO with no temporal
field. GsonAdapterTest and the ObjectIOTest* suite pass unmodified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TqsuDye2SfXGf43vuoD3n
…lags (#275)

Two pre-existing Gson wiring bugs found tracing the adapter-registration path
while fixing #275's DATE-recursion crash -- neither is the DATE bug, but they
mask each other, so they land in one commit.

Bug 1: JsonObjectReader.read(MetaObject) reads JSON via gson().fromJson(...)
but registered its builder with MetaObjectGsonInitializer.addSerializersToBuilder
(the write-side registration) instead of addDeserializersToBuilder. Unmasked
for a proxy/interface-backed MetaObject (mo.getObjectClass() is an interface,
which routes through the ALREADY-correctly-gated interface registration site,
untouched by Bug 2): on the pre-fix commit this throws
"com.google.gson.JsonIOException: Interfaces can't be instantiated!" reading
the existing Apple fixture directly through JsonObjectReader.read(mo).

Bug 2: MetaObjectGsonInitializer's addSerializer/addDeserializer capability
flags were dead code at the "multiple classes" and "otherwise" (specific-class)
registration sites -- both if-checks were present but commented out, so both
sites always registered BOTH a serializer and a deserializer regardless of
what the caller asked for. Only the interface site honored the flags. This
masking is what let Bug 1 go unnoticed for concrete-class (non-interface)
MetaObjects: addSerializersToBuilder (addDeserializer=false) still got a
working deserializer wired in anyway at these two sites.

Landing order matters: fixing Bug 2 alone first would have stopped the two
masked sites from registering a deserializer for any reader still asking for
serializers-only (Bug 1 unfixed), breaking deserialization outright via
Gson's reflective fallback. Bug 1 is fixed first, verified safe alone (Bug 2's
masking still covers the concrete-class sites), then Bug 2 is restored in the
same commit.

Pinned by a new GsonWiringBugsTest: the "otherwise" site's
addDeserializersToBuilder now wires a deserializer without also wiring a
serializer (a direct assertion on the flags, previously false); the same for
the "multiple classes" site using two existing MetaObjects (TemporalThing,
Money) that both default to ValueObject.class; and JsonObjectReader.read
round-trips directly on the existing Apple proxy fixture and on
TemporalThing. GsonAdapterTest and GsonTemporalRoundTripTest pass unmodified
(JsonObjectWriter, which uses addSerializersToBuilder, is unaffected -- it
never calls fromJson). omdb's only caller of this package
(GenericSQLDriver -> getBuilderWithAdapters) requests both flags true, so
Bug 2's fix is a no-op there, and it never uses JsonObjectReader.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TqsuDye2SfXGf43vuoD3n
…275)

Sibling defect found while fixing #275: MetaObjectDeserializer (read side)
checks mf.isArrayType() on every primitive branch and reads a JSON array
into a List; MetaObjectSerializer.writeField (write side) had no such check
anywhere -- every branch unconditionally called a scalar accessor
(mf.getBoolean(vo), mf.getInt(vo), ...), each of which is
DataConverter.toX(getObjectAttribute(obj)). For an array-valued field the
raw stored attribute is a List, and DataConverter.toX has no List case for
most primitive types, falling through to the bracketed native
List.toString() -- except DataConverter.toString(Object) (the STRING
branch), which comma-joins: a List<String> read in as ["a","b"] wrote back
out as the single string "a,b". Silent round-trip corruption.

BOOLEAN, BYTE/SHORT/INT, LONG, FLOAT/DOUBLE and STRING now check
mf.isArrayType() and, when true, write context.serialize(mf.getObject(vo))
-- Gson serializes the raw List natively as a JSON array. DATE (which had
no array handling at all) gets its own array branch, formatting each
element through TemporalWireFormat.format(mf, element) exactly as the
scalar case does per-value, including null-element and null-array-itself
handling. DECIMAL has no array form (matches the deserializer, which
explicitly has none either) and is untouched.

Pinned by a new GsonArrayWriteRoundTripTest against a new test-only fixture
(array-primitive-metadata.json; no existing fixture declared a primitive
isArray field). Test setup avoids MetaField's typed array setters
(setStringArray/setObject/setObjectArray) for populating fixtures: a
separate, pre-existing, out-of-scope defect in
MetaField.setObject(Object,Object) converts via DataConverter.toType(
getDataType(), value) -- the field's SCALAR, not EFFECTIVE, type -- so a
List is corrupted (and then rejected by setObjectAttribute's own instanceof
check) before ever reaching storage. This affects any isArray primitive
field's typed setter, MetaObjectDeserializer's own included, which is why
the deserializer-round-trip verification step is pinned as a documented,
expected-exception test rather than a working round trip (BLOCKED, not
fixed -- out of scope per the bounded-task charter: it would require
changing MetaField/DataConverter, not MetaObjectSerializer.writeField).
DATE array fixtures are built via a direct writeField() call against a
plain Map-backed value object instead, since DataConverter has no
DATE_ARRAY conversion at all (a second, deeper facet of the same
out-of-scope defect).

No production code changed outside MetaObjectSerializer.writeField.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TqsuDye2SfXGf43vuoD3n
#275)

Review of the prior commit (026bc34) flagged the LONG sibling of
GsonArrayWriteRoundTripTest's two "blocked by pre-existing setter bug"
pins: it caught bare RuntimeException and asserted only assertNotNull(
e.getMessage()) -- true for any exception with any message, from any
cause, so it could not actually serve as the regression guard its comment
claimed to be (unlike the STRING sibling, which correctly narrows to
InvalidValueException and asserts on message content).

Narrowed to catch NumberFormatException specifically and assert its
message contains "[10.0, 20.0]" -- not "[10, 20]" as the task report
originally (incorrectly) claimed. Running it confirmed why: the round trip
first passes the write-side JSON through Gson's own context.deserialize(
el, List.class), which widens JSON numbers to Double absent generic type
info (a separate, pre-existing, out-of-scope numeric-widening wart), so
DataConverter.toLong(list.toString()) actually receives [10.0, 20.0], not
the original List<Long> the fixture was built from. Comment updated to
explain the ".0" and the test-only nature of the fix.

No production code touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TqsuDye2SfXGf43vuoD3n
The Java port's object-to-JSON layer (MetaObjectSerializer / JsonObjectWriter
/ JsonObjectReader) has been shipped since early on but was never documented
in either the user-facing port docs or the agent-context skills, so an
agent/adopter had no sanctioned answer to "how do I JSON a MetaObject-backed
instance" -- and the obvious guess (a default Jackson/Gson mapper) fails
confusingly on a pojoAware-flavor class. This was deliberately gated on the
prior three commits on this branch (#275): until they landed, the layer
crashed with a StackOverflowError on any date/timestamp field, so it could
not honestly be recommended.

Five edits, all Java-scoped (the gap is Java-only -- TS/C#/Python map to
plain shapes per ADR-0019, Kotlin's own nuances are already documented):

- agent-context/skills/metaobjects-codegen/references/java.md: add
  JavaObjectCodeGenerator (module metaobjects-codegen-base, both the
  pojoAware and valueObject flavors) to the generator table, plus a new
  "Serializing generated objects" section with a write+read snippet, the
  shipped temporal wire form (matching TemporalWireFormat, #275), the
  pojoAware-back-reference caveat, and the codegen-spring record surface as
  the plain-Jackson-friendly alternative.
- agent-context/skills/metaobjects-prompts/references/java.md: one
  paragraph distinguishing the codegen-spring extractLenient path (plain
  records, safe with any mapper) from the codegen-base flavored extractor /
  raw MetaObjectExtractor (MetaObjectAware instances -> the JSON layer).
- agent-context/skills/metaobjects-runtime-ui/references/java.md: after the
  "rows are ValueObject instances" passage, explain that a ValueObject is a
  Map (so a default mapper likely won't hard-fail) but the MetaObjects JSON
  layer stays the sanctioned path because it applies the temporal wire form.
- agent-context/templates/always-on.md.mustache: one JVM-prefixed principle
  line (this template is stack-neutral).
- docs/ports/java.md: mirrors the codegen skill's generator-table + new
  "Serializing generated objects" section, pitched at the user-facing
  register.

Also corrects the issue's own suggested fix #2, which is inverted: the
pojoAware flavor emits `class <Name> extends PojoObject`, and PojoObject's
public getMetaData() back-reference is exactly what breaks a default
Jackson/Gson mapper -- it is the problem, not the solution. The
default-Jackson-friendly answer is the codegen-spring record surface.

Two agent-context passages initially spelled out the real Java package paths
(com.metaobjects.io.object.json, .generator.direct.object.javacode) in full;
rephrased to name the classes without the dotted FQN after
agent-context/test/agent-context/drift.test.ts's vocabulary-drift regex
(which scans for `object.<lowercase>` as a metamodel-subtype reference)
flagged them as false positives -- "object" as a Java package segment, not
as the object.value/entity/projection metamodel type. The full FQNs are
unaffected in docs/ports/java.md, which that test doesn't scan.

fixtures/agent-context-conformance/*/expected/** are regenerated golden
snapshots of the agent-context bundle (via the actual assemble() function,
not hand-edited) -- the mechanical, expected consequence of editing
always-on.md.mustache and the two java.md skill references; no new
conformance scenarios or metamodel fixtures added.

No product code, tests, or metadata fixtures changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TqsuDye2SfXGf43vuoD3n
…-refutation (#273)

Task D review round 1 findings, all fixed:

CRITICAL — the "Serializing generated objects" snippet in
agent-context/skills/metaobjects-codegen/references/java.md used
JsonObjectWriter/JsonObjectReader with no import, and the same package was
named only in vague prose in the runtime-ui reference. Root cause: both
mentions had been rephrased in the prior commit to dodge a false positive in
drift.test.ts's vocabulary-drift regex, which treats any `object.<lowercase>`
substring as a claimed object.<subtype> metamodel reference -- including
inside a Java package name (com.metaobjects.io.object.json). For an agent
writing Java without an IDE's auto-import, an unresolved-symbol snippet in
the one doc meant to prevent exactly that failure is a real regression.

Per the reviewer's authorization (overriding the docs-only constraint for
this one named fix), corrected the root cause instead of the symptom: all
four drift.test.ts regexes (field./object./source./template.) gain a
negative lookbehind excluding a preceding dot or word character
((?<![A-Za-z0-9_.])), so a dotted Java FQN segment no longer misfires while
a genuine bare metamodel-subtype mention (always preceded by whitespace, a
backtick, a quote, a paren, an asterisk, or line start) still does. Verified
by temporarily appending object.bogus/source.bogus/template.bogus/field.bogus
to agent-context/README.md, confirming the suite went RED, then reverting
(not committed). With the regex fixed, restored the real FQN in both files:
an explicit `import com.metaobjects.io.object.json.JsonObjectWriter;` /
`JsonObjectReader;` pair in the codegen snippet, and the plain package name
in the runtime-ui prose and the codegen generator-table row.

IMPORTANT — docs/ports/java.md L175 claimed "no typed entity POJO", written
before this branch's own new "Serializing generated objects" section
introduced JavaObjectCodeGenerator's pojoAware flavor two screens down,
which is exactly that (verified against PojoAwareCodeWriter.java /
JavaCodeWriter.java, same as originally). Reworded to scope the claim to
codegen-spring specifically and cross-reference the new section.

Regenerated the golden snapshots this touches
(fixtures/agent-context-conformance/{java-react,java-kotlin-react-tanstack}/expected/**,
4 files) via the project's own assemble() function, same as the prior
commit, and re-ran the full sdk suite (150/150 green).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TqsuDye2SfXGf43vuoD3n
…comment (#275)

Final review fix wave (3 Important findings):

- TemporalWireFormat.fractionalSuffix used String.format("%03d", millis)
  without an explicit locale, so java.util.Formatter rendered digits through
  the JVM's default FORMAT locale (e.g. ar-EG -> non-ASCII Arabic-Indic
  digits), producing a wire string TemporalWireFormat.parse itself cannot
  read back. Pin Locale.ROOT. Adds a regression test that pins the fraction
  under a non-Latin-digit default locale, with the previous default restored
  in a finally block.

- Normalization.java (integration-tests) carries an identical unlocalised
  String.format in its own fractionalSuffix -- same Locale.ROOT fix, so the
  reference the next porter copies is correct.

- MetaObjectDeserializer's DATE-array comment claimed setObjectArray
  "bypasses DataConverter.toType/DATE_ARRAY, which is unsupported" for all
  callers. Re-derived from source: it bypasses MetaField's OWN
  DataConverter.toType call, but AbstractObjectRepresentation.setValue still
  applies DataConverter.toType(effectiveDataType, value) unconditionally on
  the default (non-proxy) representation path, and DataConverter's
  DATE_ARRAY case is unimplemented -- so a non-empty date array throws
  UnsupportedOperationException there today. Comment corrected; no behavior
  change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TqsuDye2SfXGf43vuoD3n
…e ports

Read-only investigation, no product code changed. Classifies findings in three
domains -- date/time types, auto-increment identity generation, and the DB
timezone boundary -- across all five ports as over-forcing, under-documenting,
wrong-default, or deliberate-and-correct, with every claim anchored to file:line.

Written independently of the #275/#273 batch it happens to share a branch with;
kept as its own commit so it can be read, reverted, or cherry-picked alone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TqsuDye2SfXGf43vuoD3n
Units A-D, the free-text sweep and the whole-branch review are done; the plan's
own STATUS block still read all-unchecked. Flips the boxes with their commit
SHAs and records the three items deliberately carried out of this batch --
MetaField.setObject's scalar-vs-effective conversion, DataConverter's
unimplemented DATE_ARRAY case, and the still-untested OMDB jsonb temporal path --
so the next person finds them in the plan rather than only in a gitignored ledger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TqsuDye2SfXGf43vuoD3n
@dmealing
dmealing merged commit ce90dab into main Aug 8, 2026
1 check passed
@dmealing
dmealing deleted the fix/serializer-date-recursion-and-json-docs branch August 8, 2026 21:22
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.

Docs: no guidance on serializing MetaObjectAware / PojoObject instances — consumers hand-roll Jackson workarounds

1 participant