fix(metadata): repair temporal recursion, gson wiring, and array writes in Java serializer - #277
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
MetaObjectSerializerno longer passes its containing object back tocontext.serialize()onfield.date/field.timestamp(the StackOverflowError); a newTemporalWireFormathelper 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.JsonObjectReadernow registers deserializers instead of serializers, and the previously-commentedaddSerializer/addDeserializerflags inMetaObjectGsonInitializerare restored — and the serializer's write side honorsisArrayType()so array fields emit real JSON arrays rather than comma-joined strings.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).Evidence: GREEN — fixed real-API write: Orange with pickedDate
{"@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"}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)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 emptybun 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 installmvn -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.