Skip to content

Prelude to #210: retire redundant payload origin-typing (Kotlin, Python) and close Java's KNOWN_GAPS entry as moot #270

Closed

Description

@dmealing

Summary

Payload-origin handling diverges across the five ports today, and the divergence is
load-bearing for nothing. Retiring it is output-identical and de-risks #210.

Port Today
Kotlin KotlinPayloadGenerator resolves origin.* for payload field typing
Python payload_vo_generator resolves origin.* for payload field typing
TypeScript origin-blind
C# origin-blind
Java standing KNOWN_GAPS deferral (codegen-spring/.../KNOWN_GAPS.md), also listed as an open question in CLAUDE.md

The divergence is benign only because #185 made origin.passthrough type-preserving
(ERR_PASSTHROUGH_TYPE_MISMATCH, with @convert: true as the opt-out). A passthrough field's
declared type must already match its @from source, so deriving the payload field's type through
the origin is redundant with reading the declared type — the two can no longer disagree.

Why now

This is the first of two non-breaking preludes to
#210 (assembly origins retire from
object.value). Doing it first means #210's payload-tier change lands against five ports that
agree, instead of three different behaviours. Payload emission is this repo's recent cross-port
bug hotspot — ADR-0044 (#219/#220) took three stages to land, and #228 found the extract tier
resolving payload refs package-blind — so reducing the surface before the breaking change is
worth more than the diff suggests.

Scope

  • Kotlin — delete the origin-resolution path in KotlinPayloadGenerator; type payload fields
    from the declared field type.
  • Python — same in payload_vo_generator.
  • Java — close the KNOWN_GAPS payload-origin.* entry as moot (not "deferred"), and
    drop the matching open question from CLAUDE.md.
  • TypeScript / C# — no change; verify they stay origin-blind.

Gate

Output must be byte-identical in every port — this is a dead-code removal, not a behaviour
change. Pin it: run the render / payload conformance corpora before and after and diff the
emitted payload records. Any byte that moves means the redundancy claim above is wrong for some
shape, and that shape belongs in a fixture before this lands.

Non-goals

Activity

  1. added a commit that references this issue on Aug 5, 2026
  2. dmealing commented on Aug 6, 2026

    @dmealing
    MemberAuthor

    Premise falsified — needs recon before any deletion. This issue was scoped as retiring "redundant" payload origin-typing in Kotlin/Python, on the grounds that #185 made origin.passthrough type-preserving so deriving the payload field's type through the origin is redundant with reading the declared type.

    That holds for passthrough. It does not hold for the rest of what those generators do:

    • origin.aggregate — @agg count → Long, @agg avg → Double
    • origin.collection — @via → List<TargetPayload>

    There the origin genuinely determines the type and the declared field type cannot express it. The corpus exercises both: fixtures/conformance/origin-collection-simple and fixtures/conformance/flattened-kitchen-sink each carry origins on an object.value.

    So deleting the handling would not be byte-identical (this issue's own stated gate). It would either break real type derivation, or make Kotlin/Python match TypeScript's origin-blindness — and TS being origin-blind may be the actual defect rather than the baseline worth converging on. That is the open question this issue should answer first.

    Same failure mode as #271, which was also filed on a premise that recon overturned. Recommend: recon what each port derives from payload origins today and which behavior is correct, then decide what (if anything) retires.

  3. dmealing commented on Aug 6, 2026

    @dmealing
    MemberAuthor

    Recon complete — re-scoped. This is a bug fix, not a redundancy retirement, and the direction is the opposite of what this issue assumed.

    TypeScript's origin-blindness is correct; Kotlin's and Python's origin-derived payload typing is the defect.

    What each port does today

    port behavior file
    TS origin-blind — declared subtype + declared @objectRef nesting codegen-ts/src/payload-codegen.ts
    C# origin-blind (zero origin refs) MetaObjects.Codegen/PayloadCodegen.cs
    Java origin-blind (zero origin refs) codegen-spring/.../SpringPayloadGenerator.java
    Kotlin full origin dispatch codegen-kotlin/.../KotlinPayloadGenerator.kt (resolveCollectionType)
    Python same dispatch payload_vo_generator.py (_resolve_collection_type)

    Why declared-type-authoritative is right. The loader already guarantees declared ≡ derived everywhere it is checkable: passthrough (#185 ERR_PASSTHROUGH_TYPE_MISMATCH), computed (ERR_COMPUTED_TYPE_MISMATCH), first, and aggregate shape (#195). In the two places it does not pin, the origin-derivers are on the wrong side:

    1. origin.collection discards the declared @objectRef and substitutes the @via relationship's entity. On fixtures/conformance/origin-collection-simple — declared field.object isArray @objectRef PostBrief, @via Author.posts → entity Post — Kotlin/Python emit List<PostPayload> shaped from the full entity while TS/C# emit the declared PostBrief. That silently defeats the payload-bloat contract ("the declared field set IS the exposure"), which is the point of the prompt pillar.
    2. Numeric width — a declared field.int with @agg count loads legally; Kotlin hardwires LONG, contradicting the declaration.

    Python's own test encodes the pathology: a declared field.string asserted as list[PostPayload] (test_payload_vo_generator.py:304). Nothing gates payload typing cross-port — render-conformance has zero origin fixtures — which is how a 3-vs-2 divergence survived.

    Re-scoped work: delete the Kotlin/Python payload origin dispatch, including the origin.collection walk in each port's ADR-0044 name-map closure and the extract tier (which shares the name map, #228) — they must move in lockstep per port. Nullability falls back to declared @required. Add a declared-VO-vs-@via-entity disagreement fixture per port asserting the declared shape wins. Also closes the repo-root CLAUDE.md open question ("Payload origin.* resolution in codegen-spring") as moot — the KNOWN_GAPS.md entry it cites no longer exists.

    Scope: Maven + PyPI product code; rides the next coordinated cut. Nothing from this shipped in 0.20.15.

    Stop signal for whoever executes: if any Kotlin/Python payload snapshot shows declared and derived disagreeing beyond the collection/width cases above, stop before deleting — a shipped consumer could be relying on origin-derived typing.

  4. dmealing commented on Aug 7, 2026

    @dmealing
    MemberAuthor

    Shipped in 0.20.16 / 7.20.16 — a coordinated PATCH across all four registries (npm 0.20.16 · PyPI 0.20.16 · NuGet 0.20.16 · Maven 7.20.16).

    Merged as #274 (18792e52); commits c5ad0802 → 2d67696d → 34705ce9 → 608f4217 → 9af7558b → ebd71ebf. local-ci green 9/9 on the merge SHA.

    Scope correction: this issue's premise was wrong about Java

    The issue (and the ruling in spec/roadmap.md) recorded TS / C# / Java as origin-blind reference emitters, with only Kotlin and Python affected. That is false for Java. SpringPayloadGenerator carried the identical origin dispatch — imports at L12–16, the contract documented in its Javadoc at L63–75, dispatch at L453–461, @agg count→Long at L531–532, the @via walk at L552, plus a CollectionOrigin edge in the #228-shared ADR-0044 name-map closure at L255–272 that resolved its via-target with no subtype filter at all. SpringPayloadGeneratorTest pinned the pathology behaviorally. TypeScript and C# were independently re-verified as genuinely origin-blind.

    Root cause of the miss, worth recording: the file contained two raw NUL bytes in string literals (composite map-key delimiters written as literal 0x00 rather than the \0 escape). That made it test as binary, so binary-skipping search tools returned nothing for Origin — read as "zero matches" when real grep -c reports 22. Both NUL bytes are now proper escapes (runtime-identical), so the file is greppable again.

    Java was fixed here rather than the ruling being narrowed, because #210 makes prompt payloads object.projections and projections legitimately carry assembly origins — an origin-dispatching payload emitter would fire on the shape about to become the norm.

    What shipped

    All three affected ports (Kotlin, Python, Java) now type a payload field only from its declared field.<subType> + @isArray + @objectRef, never derive nullability from origin semantics, and walk the nested-payload closure only over declared field.object @objectRef edges. A field carrying any origin.* child types exactly as if the child were absent; a non-object field carrying origin.collection contributes no nested class. The origin.collection edge is deleted in lockstep from the ADR-0044 name-map closure each port shares with its extract tier (#228).

    Gating. A new disagreement test per port: a field declared field.object @objectRef: <CuratedVO> @isArray that also carries an origin.collection @via at a different, fuller entity now asserts the declared curated VO wins and that the closure emits the curated VO's class, not the entity's. Each was observed RED against the old behavior first (pre-fix Python emitted posts: list[PostPayload] | None = None). A separate two-package collision-naming gate per port pins the closure edit itself, which was initially ungated because both emitters fall back to a bare <Short>Payload on a name-map miss — so the disagreement tests alone would have passed even if the closure returned nothing.

    TypeScript and C# — the reference emitters — were previously ungated entirely, which is how this divergence survived long enough to be written into a ruling as settled fact. Each now carries a regression pin. Test-only: neither port has a changed product file, so npm and NuGet are version-parity bumps.

    Byte-identical proof: server/java/codegen-kotlin/src/test/resources/snapshots/payload-with-origins/** is unchanged — its fixture is constructed so declared == derived on every field, making it the declared==derived gate. All other Kotlin snapshot output unchanged.

    Adopter-visible

    • Java: a payload component declaring a plain scalar array changes from T to java.util.List<T> — array-ness was being dropped (Kotlin and Python already wrapped it; Java honored @isArray for enums and object refs but not plain scalars). Generated api-docs correspondingly document such a component as optional rather than required.
    • Kotlin: origin.first / origin.computed were the only producers of a nullable T? payload property and both now emit T. Kotlin's payload emitter has never read @required, so there is currently no declared route to a nullable payload property in that port — a generated @Serializable template.output class will now throw on a JSON null where it previously yielded null.
    • Python: nested payload class docstrings now read "object field target" rather than the now-false "collection target".

    Also closes the long-standing codegen-spring "payload origin.* resolution" open question as moot — origin-blindness is the intended contract, not a gap.

    See CHANGELOG.md [0.20.16].

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:metamodelMetamodel vocabulary / loader

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions