From bb7eb3342af2b82d4770c5d482aa1c2138e8505f Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 8 Jul 2026 20:37:19 -0400 Subject: [PATCH 1/3] feat(metamodel): origin.passthrough is type-preserving unless @convert (#185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A field carrying `origin.passthrough @from: "Entity.field"` forwards the source value unchanged, so its declared `field.` and array-ness must be identical to the resolved source field. A divergence now fails load with `ERR_PASSTHROUGH_TYPE_MISMATCH` — catching the mismodeling where a `field.uuid` source is surfaced by a projection/view as `field.string` (which silently leaves the field String-typed end-to-end and forces hand-written String<->UUID bridging, defeating a UUID migration). `verify --db` can't see this — the column IS uuid; only the metadata model knows both ends of the passthrough. The check compares subType + array-ness only — nullability is deliberately NOT judged (a view over an outer join legitimately widens NOT NULL -> nullable, so a nullability check would false-positive on valid projections). Escape hatch: a new optional boolean attr `@convert: true` on `origin.passthrough` acknowledges a deliberate type change and suppresses the error. It is an acknowledgement ONLY — it does not generate a cast; the consumer owns any coercion. Real type-converting projections remain origin.expression's job (#159), so @convert is NOT blocked on that unbuilt feature. This GENERALIZES and RETIRES the narrow, stored-proc-parameter-ref-only `ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH` (FR-015) into one host-agnostic invariant covering projections, entities, values, and parameter refs alike, enforced once in the origin-paths validation pass. Cross-port: implemented in all five ports (TS reference + Python / C# / Java; Kotlin inherits Java's loader), each registering @convert on origin.passthrough and byte-matching the shared registry-conformance manifest. Gated by three new conformance fixtures (type-mismatch, array-mismatch, @convert opt-out) plus the repointed parameter-ref fixture. BREAKING: adopter metadata with a divergent passthrough now fails load (the point — it names both types + the fix). Also fixes a pre-existing generator gap: the `index` metamodel concept was missing from generate-embedded-metamodel.ts's CONCEPT_DIRS. Co-Authored-By: Claude --- CHANGELOG.md | 6 ++ .../skills/metaobjects-authoring/SKILL.md | 12 +++ .../skills/metaobjects-authoring/SKILL.md | 12 +++ .../skills/metaobjects-authoring/SKILL.md | 12 +++ .../skills/metaobjects-authoring/SKILL.md | 12 +++ .../skills/metaobjects-authoring/SKILL.md | 12 +++ fixtures/conformance/ERROR-CODES.json | 2 +- .../expected-errors.json | 17 ++++ .../input/meta.demo.json | 36 +++++++ .../providers.json | 1 + .../expected-errors.json | 17 ++++ .../input/meta.demo.json | 36 +++++++ .../providers.json | 1 + .../expected-errors.json | 8 +- .../expected.json | 75 +++++++++++++++ .../input/meta.demo.json | 41 ++++++++ .../providers.json | 1 + fixtures/metamodel-docs/expected/providers.md | 2 +- .../metamodel-docs/expected/types/origin.md | 1 + .../expected-registry.json | 7 ++ scripts/generate-embedded-metamodel.ts | 1 + server/csharp/MetaObjects/Errors.cs | 14 ++- .../MetaObjects/Loader/ValidationPasses.cs | 84 ++++++++++------- .../Persistence/Origin/OriginConstants.cs | 4 + .../Persistence/Origin/OriginSchema.cs | 6 ++ .../MetaObjects/SpecMetamodel/origin.json | 3 +- .../main/java/com/metaobjects/ErrorCode.java | 16 +++- .../metaobjects/loader/ValidationPhase.java | 94 ++++++++++--------- .../com/metaobjects/origin/MetaOrigin.java | 24 +++++ .../metaobjects/origin/PassthroughOrigin.java | 7 ++ server/python/src/metaobjects/core_types.py | 5 +- server/python/src/metaobjects/errors.py | 8 +- .../loader/validate_source_parameter_ref.py | 77 +++------------ .../metaobjects/loader/validation_passes.py | 55 +++++++++++ .../persistence/origin/origin_constants.py | 4 + .../metaobjects/spec_metamodel/origin.json | 3 +- server/python/uv.lock | 2 +- .../core/index/index-definition.embedded.ts | 7 +- .../packages/metadata/src/errors.ts | 9 +- .../metadata/src/loader/validation-passes.ts | 54 +++++++++++ .../persistence/origin/origin-constants.ts | 4 + .../origin/origin-definition.embedded.ts | 8 ++ .../source/validate-source-parameter-ref.ts | 60 +++--------- .../test/fr015-source-parameter-ref.test.ts | 12 ++- .../origin-definition-completeness.test.ts | 3 + spec/metamodel/origin.json | 3 +- 46 files changed, 670 insertions(+), 208 deletions(-) create mode 100644 fixtures/conformance/error-origin-passthrough-array-mismatch/expected-errors.json create mode 100644 fixtures/conformance/error-origin-passthrough-array-mismatch/input/meta.demo.json create mode 100644 fixtures/conformance/error-origin-passthrough-array-mismatch/providers.json create mode 100644 fixtures/conformance/error-origin-passthrough-type-mismatch/expected-errors.json create mode 100644 fixtures/conformance/error-origin-passthrough-type-mismatch/input/meta.demo.json create mode 100644 fixtures/conformance/error-origin-passthrough-type-mismatch/providers.json create mode 100644 fixtures/conformance/origin-passthrough-convert-optout/expected.json create mode 100644 fixtures/conformance/origin-passthrough-convert-optout/input/meta.demo.json create mode 100644 fixtures/conformance/origin-passthrough-convert-optout/providers.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e401617b9..52761022d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added +- **`@convert` on `origin.passthrough` — acknowledge a deliberate passthrough type change (#185).** A new optional boolean attr. Absent/false (the default), a passthrough is type-preserving (see Changed, below); `@convert: true` opts a field out of the type-equality check when its type intentionally differs from its `@from` source. It is an **acknowledgement only — it does NOT generate a cast**; the value flows through unchanged and the consumer owns any coercion. Real type-converting projections remain `origin.expression`'s job (#159). Registered on `origin.passthrough` in all five ports (cross-port registry-conformance gated). + +### Changed +- **BREAKING — `origin.passthrough` is now type-preserving: a passthrough field must match its `@from` source's type (#185).** A field carrying `origin.passthrough @from: "Entity.field"` forwards another field's value unchanged, so its declared `field.` and array-ness must be identical to the resolved source field. A divergence now fails load with `ERR_PASSTHROUGH_TYPE_MISMATCH` (e.g. a `field.uuid` source surfaced by a projection as `field.string` — the exact mismodeling that forces hand-written `String↔UUID` bridging and defeats a UUID migration). The check compares **subType and array-ness only — nullability is deliberately not judged** (a view over an outer join legitimately widens `NOT NULL` → nullable). Opt out of a deliberate type change with `@convert: true` (see Added). This **generalizes and retires** the narrow, stored-proc-parameter-ref-only `ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH` (FR-015) into one host-agnostic invariant covering projections, entities, values, and parameter refs alike. Enforced in the loader/verify in all five ports (TS / Python / C# / Java / Kotlin), cross-port conformance-gated. **Migration:** if load newly fails, the error names both the declared type and the source type — declare the source type (usually the fix — the projection was wrong), or add `@convert: true` if the divergence is intentional. + ## [0.15.15] — 2026-07-07 _npm `@metaobjectsdev/sdk` + `@metaobjectsdev/cli` `0.15.15` (isolated patch; the other 12 packages stay `0.15.14`). Ships updated agent-context skills — a docs/content change bundled into the SDK and delivered through the CLI (`meta agent-docs` / `meta init`). No runtime or generated-code change; PyPI / NuGet / Maven are unaffected._ diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index 826e4ce2b..2ce3cdc27 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -601,6 +601,18 @@ child (`source.rdb: { kind: view, table: v_author }`) — codegen keys projectio detection + view DDL off that read-only source, so without it `meta gen` emits nothing for the projection. +**A `passthrough` field must match its `@from` source's type.** A passthrough +forwards the source value unchanged, so the projection field's `field.` +and array-ness must be identical to the source field's — a `field.uuid` source +declared as `field.string` on the projection fails load with +`ERR_PASSTHROUGH_TYPE_MISMATCH` (this is exactly the mismodeling that leaves a +view `String`-typed over a `uuid` column and forces hand-written coercion). +Declare the source's type. If the type genuinely must differ on purpose, set +`@convert: true` on the `origin.passthrough` to acknowledge it — an +acknowledgement only, it does **not** generate a cast (you own any coercion). +Nullability may differ (an outer-join view legitimately widens `NOT NULL` → +nullable) — only subType + array-ness are checked. + ## Abstracts + `extends` (deferred resolution) + `overlay` An **abstract** node (`abstract: true`) describes a shape but is never emitted as diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index 826e4ce2b..2ce3cdc27 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -601,6 +601,18 @@ child (`source.rdb: { kind: view, table: v_author }`) — codegen keys projectio detection + view DDL off that read-only source, so without it `meta gen` emits nothing for the projection. +**A `passthrough` field must match its `@from` source's type.** A passthrough +forwards the source value unchanged, so the projection field's `field.` +and array-ness must be identical to the source field's — a `field.uuid` source +declared as `field.string` on the projection fails load with +`ERR_PASSTHROUGH_TYPE_MISMATCH` (this is exactly the mismodeling that leaves a +view `String`-typed over a `uuid` column and forces hand-written coercion). +Declare the source's type. If the type genuinely must differ on purpose, set +`@convert: true` on the `origin.passthrough` to acknowledge it — an +acknowledgement only, it does **not** generate a cast (you own any coercion). +Nullability may differ (an outer-join view legitimately widens `NOT NULL` → +nullable) — only subType + array-ness are checked. + ## Abstracts + `extends` (deferred resolution) + `overlay` An **abstract** node (`abstract: true`) describes a shape but is never emitted as diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md index 826e4ce2b..2ce3cdc27 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -601,6 +601,18 @@ child (`source.rdb: { kind: view, table: v_author }`) — codegen keys projectio detection + view DDL off that read-only source, so without it `meta gen` emits nothing for the projection. +**A `passthrough` field must match its `@from` source's type.** A passthrough +forwards the source value unchanged, so the projection field's `field.` +and array-ness must be identical to the source field's — a `field.uuid` source +declared as `field.string` on the projection fails load with +`ERR_PASSTHROUGH_TYPE_MISMATCH` (this is exactly the mismodeling that leaves a +view `String`-typed over a `uuid` column and forces hand-written coercion). +Declare the source's type. If the type genuinely must differ on purpose, set +`@convert: true` on the `origin.passthrough` to acknowledge it — an +acknowledgement only, it does **not** generate a cast (you own any coercion). +Nullability may differ (an outer-join view legitimately widens `NOT NULL` → +nullable) — only subType + array-ness are checked. + ## Abstracts + `extends` (deferred resolution) + `overlay` An **abstract** node (`abstract: true`) describes a shape but is never emitted as diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md index 826e4ce2b..2ce3cdc27 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -601,6 +601,18 @@ child (`source.rdb: { kind: view, table: v_author }`) — codegen keys projectio detection + view DDL off that read-only source, so without it `meta gen` emits nothing for the projection. +**A `passthrough` field must match its `@from` source's type.** A passthrough +forwards the source value unchanged, so the projection field's `field.` +and array-ness must be identical to the source field's — a `field.uuid` source +declared as `field.string` on the projection fails load with +`ERR_PASSTHROUGH_TYPE_MISMATCH` (this is exactly the mismodeling that leaves a +view `String`-typed over a `uuid` column and forces hand-written coercion). +Declare the source's type. If the type genuinely must differ on purpose, set +`@convert: true` on the `origin.passthrough` to acknowledge it — an +acknowledgement only, it does **not** generate a cast (you own any coercion). +Nullability may differ (an outer-join view legitimately widens `NOT NULL` → +nullable) — only subType + array-ness are checked. + ## Abstracts + `extends` (deferred resolution) + `overlay` An **abstract** node (`abstract: true`) describes a shape but is never emitted as diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index 826e4ce2b..2ce3cdc27 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -601,6 +601,18 @@ child (`source.rdb: { kind: view, table: v_author }`) — codegen keys projectio detection + view DDL off that read-only source, so without it `meta gen` emits nothing for the projection. +**A `passthrough` field must match its `@from` source's type.** A passthrough +forwards the source value unchanged, so the projection field's `field.` +and array-ness must be identical to the source field's — a `field.uuid` source +declared as `field.string` on the projection fails load with +`ERR_PASSTHROUGH_TYPE_MISMATCH` (this is exactly the mismodeling that leaves a +view `String`-typed over a `uuid` column and forces hand-written coercion). +Declare the source's type. If the type genuinely must differ on purpose, set +`@convert: true` on the `origin.passthrough` to acknowledge it — an +acknowledgement only, it does **not** generate a cast (you own any coercion). +Nullability may differ (an outer-join view legitimately widens `NOT NULL` → +nullable) — only subType + array-ness are checked. + ## Abstracts + `extends` (deferred resolution) + `overlay` An **abstract** node (`abstract: true`) describes a shape but is never emitted as diff --git a/fixtures/conformance/ERROR-CODES.json b/fixtures/conformance/ERROR-CODES.json index 4fde2917a..4daebe62d 100644 --- a/fixtures/conformance/ERROR-CODES.json +++ b/fixtures/conformance/ERROR-CODES.json @@ -32,6 +32,7 @@ "ERR_AMBIGUOUS_REF": "Cross-package reference contract — a BARE reference (no `::`) names an object that exists in more than one package and none is in the referrer's own package. The reference is ambiguous; qualify it with the package (FQN). Applies to every object-ref-bearing attribute (@objectRef, @references, @from/@of/@via heads, extends, @payloadRef/@responseRef, @through).", "ERR_ORIGIN_CARDINALITY": "FR-024 (ADR-0029): origin cardinality contract broken — a passthrough @via path crosses a to-many hop (row-multiplying passthrough — you meant aggregate), or an aggregate @via path is to-one at every hop (aggregating over a to-one path — you meant passthrough). Checked on explicit AND inferred paths.", "ERR_EXTENDS_ORIGIN_MISMATCH": "FR-024 (ADR-0029 decision 7): a field declares both an entity-nested extends (shape lineage) and an origin.passthrough @from (data lineage) and they disagree — the resolved @from target is not the field's resolved extends target (nor anywhere on its extends chain). Host-agnostic (projections, entities, values). origin.aggregate is never judged (it computes something new); a top-level abstract extends target is never judged (shape-only reuse makes no lineage claim).", + "ERR_PASSTHROUGH_TYPE_MISMATCH": "#185: a field carrying origin.passthrough @from: \"Entity.field\" declares a field. or array-ness that differs from its resolved source field — a passthrough forwards the value unchanged, so its type must be identical. Host-agnostic (projections, entities, values, stored-proc parameter refs). Nullability is NOT judged (a view over an outer join legitimately widens NOT NULL → nullable). Opt out of a deliberate type change with @convert: true on the origin.passthrough (an acknowledgement — it does not generate a cast); real type-converting projections are origin.expression's job (#159).", "ERR_DERIVED_FIELD_NO_READ_SOURCE": "FR-024 (spec §7): an object.entity field carrying an origin.* child is derived (read-only) and must be providable — the entity must declare at least one source with a read-only @kind (view/materializedView/storedProc/tableFunction, e.g. a @role replica view beside the table primary). Table-only or source-less entities with origin-bearing fields error on the field. Projections and object.value hosts are exempt. Until the Phase-E B4b cutover removes view-primary entities, a read-only-kind PRIMARY source also counts as providable (legacy spelling).", "ERR_INVALID_TEMPLATE": "A template (prompt/output) declares a @payloadRef that does not resolve, or @requiredSlots that are not fields on its payload.", "ERR_INVALID_RELATIONSHIP": "FR-017: a M:N relationship's slim vocabulary is invalid — @through does not name a junction declaring two identity.reference children, @sourceRefField does not match one of them, or a M:N-only attr (@through/@sourceRefField/@symmetric) is set on a non-M:N (1:N / @cardinality:one) relationship; OR a relationship's @objectRef does not resolve to any object in the loaded tree (a dangling target).", @@ -54,7 +55,6 @@ "ERR_PARAMETER_REF_UNRESOLVED": "FR-015: source.rdb @parameterRef names an object that does not exist in the loaded model.", "ERR_PARAMETER_REF_NOT_VALUE_OBJECT": "FR-015: source.rdb @parameterRef references an object.entity instead of object.value. Input shapes are value-objects by definition (no identity).", "ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND": "FR-015: source.rdb @parameterRef is set with @kind: \"table\" / \"view\" / \"materializedView\". These kinds do not accept parameters; only \"storedProc\" / \"tableFunction\" do.", - "ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH": "FR-015: a parameter field uses origin.passthrough @from: \"Entity.field\" but the parameter's subtype does not match the referenced field's subtype (drift gate).", "ERR_DISCRIMINATOR_FIELD_NOT_FOUND": "FR-014: object.entity @discriminator names a field that does not exist on the entity (including inherited fields).", "ERR_DISCRIMINATOR_VALUE_DUPLICATE": "FR-014: two subtypes of the same @discriminator-bearing root declare the same @discriminatorValue.", "ERR_DISCRIMINATOR_VALUE_MISSING": "FR-014: a concrete entity extends a chain whose root declares @discriminator but the subtype lacks @discriminatorValue.", diff --git a/fixtures/conformance/error-origin-passthrough-array-mismatch/expected-errors.json b/fixtures/conformance/error-origin-passthrough-array-mismatch/expected-errors.json new file mode 100644 index 000000000..4adba621a --- /dev/null +++ b/fixtures/conformance/error-origin-passthrough-array-mismatch/expected-errors.json @@ -0,0 +1,17 @@ +{ + "errors": [ + { + "code": "ERR_PASSTHROUGH_TYPE_MISMATCH", + "source": { + "format": "resolved", + "files": [ + "meta.demo.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.projection'].children[2]['field.string'].children[0]['origin.passthrough']", + "referrer": "ProductView::tags", + "target": "demo::Product.tags" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-origin-passthrough-array-mismatch/input/meta.demo.json b/fixtures/conformance/error-origin-passthrough-array-mismatch/input/meta.demo.json new file mode 100644 index 000000000..e72e2df69 --- /dev/null +++ b/fixtures/conformance/error-origin-passthrough-array-mismatch/input/meta.demo.json @@ -0,0 +1,36 @@ +{ + "metadata.root": { + "package": "demo", + "children": [ + { + "object.entity": { + "name": "Product", + "children": [ + { "source.rdb": { "@table": "products" } }, + { "field.uuid": { "name": "id" } }, + { "field.string": { "name": "tags", "isArray": true } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } } + ] + } + }, + { + "object.projection": { + "name": "ProductView", + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_product" } }, + { "field.uuid": { "name": "id", "extends": "demo::Product.id" } }, + { + "field.string": { + "name": "tags", + "children": [ + { "origin.passthrough": { "@from": "demo::Product.tags" } } + ] + } + }, + { "identity.primary": { "name": "id", "extends": "demo::Product.id" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-origin-passthrough-array-mismatch/providers.json b/fixtures/conformance/error-origin-passthrough-array-mismatch/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/error-origin-passthrough-array-mismatch/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/fixtures/conformance/error-origin-passthrough-type-mismatch/expected-errors.json b/fixtures/conformance/error-origin-passthrough-type-mismatch/expected-errors.json new file mode 100644 index 000000000..135910023 --- /dev/null +++ b/fixtures/conformance/error-origin-passthrough-type-mismatch/expected-errors.json @@ -0,0 +1,17 @@ +{ + "errors": [ + { + "code": "ERR_PASSTHROUGH_TYPE_MISMATCH", + "source": { + "format": "resolved", + "files": [ + "meta.demo.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.projection'].children[2]['field.string'].children[0]['origin.passthrough']", + "referrer": "CharacterView::currentLocationId", + "target": "demo::GameCharacter.currentLocationId" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-origin-passthrough-type-mismatch/input/meta.demo.json b/fixtures/conformance/error-origin-passthrough-type-mismatch/input/meta.demo.json new file mode 100644 index 000000000..73cd66174 --- /dev/null +++ b/fixtures/conformance/error-origin-passthrough-type-mismatch/input/meta.demo.json @@ -0,0 +1,36 @@ +{ + "metadata.root": { + "package": "demo", + "children": [ + { + "object.entity": { + "name": "GameCharacter", + "children": [ + { "source.rdb": { "@table": "game_characters" } }, + { "field.uuid": { "name": "id" } }, + { "field.uuid": { "name": "currentLocationId" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } } + ] + } + }, + { + "object.projection": { + "name": "CharacterView", + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_character" } }, + { "field.uuid": { "name": "id", "extends": "demo::GameCharacter.id" } }, + { + "field.string": { + "name": "currentLocationId", + "children": [ + { "origin.passthrough": { "@from": "demo::GameCharacter.currentLocationId" } } + ] + } + }, + { "identity.primary": { "name": "id", "extends": "demo::GameCharacter.id" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-origin-passthrough-type-mismatch/providers.json b/fixtures/conformance/error-origin-passthrough-type-mismatch/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/error-origin-passthrough-type-mismatch/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/fixtures/conformance/error-parameter-ref-passthrough-type-mismatch/expected-errors.json b/fixtures/conformance/error-parameter-ref-passthrough-type-mismatch/expected-errors.json index a01fdf5a9..da10f876b 100644 --- a/fixtures/conformance/error-parameter-ref-passthrough-type-mismatch/expected-errors.json +++ b/fixtures/conformance/error-parameter-ref-passthrough-type-mismatch/expected-errors.json @@ -1,13 +1,15 @@ { "errors": [ { - "code": "ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH", + "code": "ERR_PASSTHROUGH_TYPE_MISMATCH", "source": { - "format": "json", + "format": "resolved", "files": [ "meta.demo.json" ], - "jsonPath": "$['metadata.root'].children[1]['object.value'].children[0]['field.int']" + "jsonPath": "$['metadata.root'].children[1]['object.value'].children[0]['field.int'].children[0]['origin.passthrough']", + "referrer": "Args::label", + "target": "demo::Case.label" } } ], diff --git a/fixtures/conformance/origin-passthrough-convert-optout/expected.json b/fixtures/conformance/origin-passthrough-convert-optout/expected.json new file mode 100644 index 000000000..63b74a097 --- /dev/null +++ b/fixtures/conformance/origin-passthrough-convert-optout/expected.json @@ -0,0 +1,75 @@ +{ + "metadata.root": { + "package": "demo", + "children": [ + { + "object.entity": { + "name": "GameCharacter", + "children": [ + { + "source.rdb": { + "@table": "game_characters" + } + }, + { + "field.uuid": { + "name": "id" + } + }, + { + "field.uuid": { + "name": "currentLocationId" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.projection": { + "name": "CharacterView", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_character" + } + }, + { + "field.uuid": { + "name": "id", + "extends": "demo::GameCharacter.id" + } + }, + { + "field.string": { + "name": "currentLocationId", + "children": [ + { + "origin.passthrough": { + "@convert": true, + "@from": "demo::GameCharacter.currentLocationId" + } + } + ] + } + }, + { + "identity.primary": { + "name": "id", + "extends": "demo::GameCharacter.id" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/origin-passthrough-convert-optout/input/meta.demo.json b/fixtures/conformance/origin-passthrough-convert-optout/input/meta.demo.json new file mode 100644 index 000000000..b0360668b --- /dev/null +++ b/fixtures/conformance/origin-passthrough-convert-optout/input/meta.demo.json @@ -0,0 +1,41 @@ +{ + "metadata.root": { + "package": "demo", + "children": [ + { + "object.entity": { + "name": "GameCharacter", + "children": [ + { "source.rdb": { "@table": "game_characters" } }, + { "field.uuid": { "name": "id" } }, + { "field.uuid": { "name": "currentLocationId" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } } + ] + } + }, + { + "object.projection": { + "name": "CharacterView", + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_character" } }, + { "field.uuid": { "name": "id", "extends": "demo::GameCharacter.id" } }, + { + "field.string": { + "name": "currentLocationId", + "children": [ + { + "origin.passthrough": { + "@from": "demo::GameCharacter.currentLocationId", + "@convert": true + } + } + ] + } + }, + { "identity.primary": { "name": "id", "extends": "demo::GameCharacter.id" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/origin-passthrough-convert-optout/providers.json b/fixtures/conformance/origin-passthrough-convert-optout/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/origin-passthrough-convert-optout/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/fixtures/metamodel-docs/expected/providers.md b/fixtures/metamodel-docs/expected/providers.md index a47d8b059..e13478e1a 100644 --- a/fixtures/metamodel-docs/expected/providers.md +++ b/fixtures/metamodel-docs/expected/providers.md @@ -30,7 +30,7 @@ Core metaobjects metamodel types and subtypes. - `object.entity`: `@discriminator`, `@discriminatorValue` - `origin.aggregate`: `@agg`, `@of`, `@via` - `origin.collection`: `@via` -- `origin.passthrough`: `@from`, `@via` +- `origin.passthrough`: `@convert`, `@from`, `@via` - `relationship.aggregation`: `@cardinality`, `@objectRef`, `@onDelete`, `@onUpdate`, `@sourceRefField`, `@symmetric`, `@through` - `relationship.association`: `@cardinality`, `@objectRef`, `@onDelete`, `@onUpdate`, `@sourceRefField`, `@symmetric`, `@through` - `relationship.base`: `@cardinality`, `@objectRef`, `@onDelete`, `@onUpdate`, `@sourceRefField`, `@symmetric`, `@through` diff --git a/fixtures/metamodel-docs/expected/types/origin.md b/fixtures/metamodel-docs/expected/types/origin.md index 9bf4281cf..19eb5211c 100644 --- a/fixtures/metamodel-docs/expected/types/origin.md +++ b/fixtures/metamodel-docs/expected/types/origin.md @@ -76,6 +76,7 @@ A cross-entity field reference: this projection field passes a source entity's v | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | +| `@convert` | boolean | no | | | metaobjects-core-types | Acknowledges that this field's declared type deliberately differs from its @from source field's type (#185). Absent/false (the default), a passthrough is type-preserving — a differing field. or array-ness fails with ERR_PASSTHROUGH_TYPE_MISMATCH. Set true to opt out. This is an acknowledgement only: it does NOT generate a cast — the value flows through unchanged and the consumer owns any coercion. Real type-converting projections are origin.expression's job (#159). | | `@from` | string | yes | | | metaobjects-core-types | Dotted Entity.field reference identifying the source value this projection field passes through (e.g. 'Program.title'). | | `@via` | string | no | | | metaobjects-core-types | Optional dotted relationship path used to reach the source entity (e.g. 'Program.weeks'). | diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index 4d43b6939..feea18894 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -3137,6 +3137,13 @@ "description": "A cross-entity field reference: this projection field passes a source entity's value straight through (@from), optionally reached via a relationship path (@via).", "whenToUse": "A projection field just surfaces a field from a related entity. Declare the cross-entity passthrough instead of re-joining and re-selecting it by hand.", "attrs": [ + { + "name": "convert", + "valueType": "boolean", + "isArray": false, + "required": false, + "description": "Acknowledges that this field's declared type deliberately differs from its @from source field's type (#185). Absent/false (the default), a passthrough is type-preserving — a differing field. or array-ness fails with ERR_PASSTHROUGH_TYPE_MISMATCH. Set true to opt out. This is an acknowledgement only: it does NOT generate a cast — the value flows through unchanged and the consumer owns any coercion. Real type-converting projections are origin.expression's job (#159)." + }, { "name": "from", "valueType": "string", diff --git a/scripts/generate-embedded-metamodel.ts b/scripts/generate-embedded-metamodel.ts index 072bcef21..f4d18290a 100644 --- a/scripts/generate-embedded-metamodel.ts +++ b/scripts/generate-embedded-metamodel.ts @@ -73,6 +73,7 @@ const CONCEPT_DIRS: Record = { relationship: "core/relationship", object: "core/object", documentation: "core/documentation", + index: "core/index", // persistence/ origin: "persistence/origin", source: "persistence/source", diff --git a/server/csharp/MetaObjects/Errors.cs b/server/csharp/MetaObjects/Errors.cs index f68dfb03d..a29e68076 100644 --- a/server/csharp/MetaObjects/Errors.cs +++ b/server/csharp/MetaObjects/Errors.cs @@ -71,6 +71,14 @@ public enum ErrorCode // (projections and object.value hosts exempt). ERR_EXTENDS_ORIGIN_MISMATCH, ERR_DERIVED_FIELD_NO_READ_SOURCE, + // #185 — origin.passthrough is type-preserving: a field forwarding another + // field's value via origin.passthrough must declare the SAME field. + // and array-ness as its resolved @from source (RESOLVING/effective, ADR-0039). + // Nullability is NOT judged. Escape hatch: @convert: true acknowledges a + // deliberate type change (acknowledgement only — no generated cast). This + // UNIVERSAL check replaces the retired narrow ERR_PARAMETER_REF_PASSTHROUGH_ + // TYPE_MISMATCH (it also covers FR-015 parameter-ref value objects + array-ness). + ERR_PASSTHROUGH_TYPE_MISMATCH, // FR-024 (ADR-0028) hard cutover: an entity's PRIMARY source has a read-only @kind — // read-only kinds only in non-primary roles; a derived read model is an object.projection. @@ -124,7 +132,11 @@ public enum ErrorCode ERR_PARAMETER_REF_UNRESOLVED, ERR_PARAMETER_REF_NOT_VALUE_OBJECT, ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND, - ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH, + // ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH (retired #185): the narrow, + // subtype-only parameter-ref passthrough-type check was generalized into the + // universal ERR_PASSTHROUGH_TYPE_MISMATCH (above), which runs over every + // object — including these parameter-ref value objects — and also gates + // array-ness and honours the @convert opt-out. // FR-014 — TPH discriminator cross-attribute validation (TS reference). // C# port has not yet shipped FR-014. ERR_DISCRIMINATOR_FIELD_NOT_FOUND, diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 81e1e3b0f..762522d2e 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -388,6 +388,10 @@ public static IReadOnlyList ValidateOriginPaths(MetaData root) if (fromTarget is ResolvedFromTarget ft1) { CheckExtendsOriginAgreement(field, ft1.Field, from, obj, origin.Source, errors); + // #185 — passthrough is type-preserving unless @convert acknowledges a change. + // ADR-0039: own — origin.* never inherits (ADR-0029). + bool convert = origin.OwnAttr(ORIGIN_PASSTHROUGH_ATTR_CONVERT) is true; + CheckPassthroughType(field, ft1.Field, from, convert, obj, origin.Source, errors); } var viaObj = origin.OwnAttr(ORIGIN_PASSTHROUGH_ATTR_VIA); if (viaObj is string via && via != "") @@ -1031,6 +1035,41 @@ private static void CheckExtendsOriginAgreement( Envelope: ResolvedSource.From(originSource, $"{obj.Fqn()}::{field.Name}", fromAttr))); } + /// #185 — origin.passthrough is type-preserving. A field forwarding another + /// field's value via origin.passthrough must declare the SAME field.<subType> + /// and the same array-ness as its resolved @from source — otherwise the + /// projected type silently diverges from its source (e.g. a field.uuid surfaced + /// as field.string, forcing hand-written String↔UUID bridging). Compares the + /// RESOLVING/effective subType + isArray (ADR-0039), so a field inheriting its + /// shape via `extends` is judged on its effective type. + /// + /// Nullability is deliberately NOT judged: a view over an outer join legitimately + /// widens a NOT NULL source column to nullable, so a nullability check would + /// false-positive on valid projections. + /// + /// Escape hatch: @convert: true on the origin.passthrough acknowledges a + /// deliberate type change and suppresses the error (it does NOT emit a cast — + /// the consumer owns any coercion; real converting projections are #159's + /// origin.expression). Host-agnostic (projections, entities, values, and the + /// FR-015 stored-proc parameter refs the retired ERR_PARAMETER_REF_PASSTHROUGH_ + /// TYPE_MISMATCH used to cover). + private static void CheckPassthroughType( + MetaData field, MetaData fromField, string fromAttr, bool convert, MetaData obj, ErrorSource originSource, List errors) + { + if (convert) return; // deliberate type change acknowledged + // Compare both axes at once via the type-label: subtype names never contain + // "[]", so equal labels ⇔ same SubType AND same array-ness. + string declared = $"field.{field.SubType}{(field.ResolvedIsArray() ? "[]" : "")}"; + string source = $"field.{fromField.SubType}{(fromField.ResolvedIsArray() ? "[]" : "")}"; + if (declared == source) return; + errors.Add(new MetaError( + $"origin.passthrough on {obj.Name}.{field.Name}: field is {declared} but its @from source " + + $"\"{fromAttr}\" is {source} — a passthrough forwards the value unchanged, so the types must " + + "match. Declare " + source + ", or set @convert: true to acknowledge a deliberate type change.", + ErrorCode.ERR_PASSTHROUGH_TYPE_MISMATCH, + Envelope: ResolvedSource.From(originSource, $"{obj.Fqn()}::{field.Name}", fromAttr))); + } + // ========================================================================= // FR-024 B3 — projection identity pass-through + key correspondence. // ERR_PROJECTION_IDENTITY_NOT_EXTENDED / ERR_IDENTITY_KEY_MISMATCH. @@ -2758,8 +2797,10 @@ public static IReadOnlyList ValidateDiscriminator(MetaData root) // ========================================================================= // FR-015 — source.rdb @parameterRef typed-input rules. - // ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND / _UNRESOLVED / _NOT_VALUE_OBJECT / - // _PASSTHROUGH_TYPE_MISMATCH. Mirrors TS persistence/source/validate-source-parameter-ref.ts. + // ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND / _UNRESOLVED / _NOT_VALUE_OBJECT. + // Passthrough type-matching on parameter fields is the universal + // ERR_PASSTHROUGH_TYPE_MISMATCH (retired the narrow parameter-ref code, #185). + // Mirrors TS persistence/source/validate-source-parameter-ref.ts. // ========================================================================= private static readonly HashSet CallableKinds = new() @@ -2826,37 +2867,14 @@ public static IReadOnlyList ValidateSourceParameterRef(MetaData root) continue; } - // ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH per parameter field. - // ADR-0039: resolving — the parameter value-object's fields may be inherited - // via extends (TS validate-source-parameter-ref.ts:122). - foreach (var paramField in target.Children().Where(c => c.Type == TYPE_FIELD).Cast()) - { - // ADR-0039: own — origin.* never inherits (ADR-0029); the origin child and - // its @from are read own (TS validate-source-parameter-ref.ts:125,130). - var passthrough = paramField.OwnChildren() - .FirstOrDefault(c => c.Type == TYPE_ORIGIN && c.SubType == ORIGIN_SUBTYPE_PASSTHROUGH); - if (passthrough == null) continue; - if (passthrough.OwnAttr(ORIGIN_PASSTHROUGH_ATTR_FROM) is not string from || from.Length == 0) continue; - int dot = from.IndexOf('.'); - if (dot < 0) continue; - string targetEntityName = from.Substring(0, dot); - string targetFieldName = from.Substring(dot + 1); - if (!index.TryGetValue(targetEntityName, out var targetEntity)) continue; - // ADR-0039: resolving — the referenced entity's field may be inherited - // via extends (TS validate-source-parameter-ref.ts:139). - var targetField = targetEntity.Children() - .FirstOrDefault(c => c.Type == TYPE_FIELD && c.Name == targetFieldName) as MetaField; - if (targetField == null) continue; - if (paramField.SubType != targetField.SubType) - { - errors.Add(new MetaError( - $"parameter field \"{paramField.Name}\" (field.{paramField.SubType}) on @parameterRef " + - $"\"{refName}\" uses origin.passthrough @from: \"{from}\", but " + - $"{targetEntity.Name}.{targetFieldName} is field.{targetField.SubType}; types must match", - ErrorCode.ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH, - Envelope: paramField.Source)); - } - } + // #185 — passthrough type-preservation (parameter fields forwarding an + // entity field via origin.passthrough must match its type) is enforced + // UNIVERSALLY by CheckPassthroughType in ValidateOriginPaths (which runs + // over every object incl. these parameter-ref value-objects), emitting + // ERR_PASSTHROUGH_TYPE_MISMATCH. The narrow, subtype-only + // ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH that used to live here was + // retired in favour of that single invariant (which also gates array-ness + // and honours the @convert opt-out). } } diff --git a/server/csharp/MetaObjects/Persistence/Origin/OriginConstants.cs b/server/csharp/MetaObjects/Persistence/Origin/OriginConstants.cs index bba9ab0e8..b0fb6c1ba 100644 --- a/server/csharp/MetaObjects/Persistence/Origin/OriginConstants.cs +++ b/server/csharp/MetaObjects/Persistence/Origin/OriginConstants.cs @@ -30,6 +30,10 @@ public static class OriginConstants // passthrough attrs public const string ORIGIN_PASSTHROUGH_ATTR_FROM = "from"; public const string ORIGIN_PASSTHROUGH_ATTR_VIA = "via"; + // @convert (boolean): acknowledges a deliberate type change vs the @from source + // (#185). Suppresses ERR_PASSTHROUGH_TYPE_MISMATCH. Acknowledgement only — it + // does NOT generate a cast; real type-converting projections are origin.expression (#159). + public const string ORIGIN_PASSTHROUGH_ATTR_CONVERT = "convert"; // aggregate attrs public const string ORIGIN_AGGREGATE_ATTR_AGG = "agg"; diff --git a/server/csharp/MetaObjects/Persistence/Origin/OriginSchema.cs b/server/csharp/MetaObjects/Persistence/Origin/OriginSchema.cs index 920b8f028..3ff5e2f78 100644 --- a/server/csharp/MetaObjects/Persistence/Origin/OriginSchema.cs +++ b/server/csharp/MetaObjects/Persistence/Origin/OriginSchema.cs @@ -23,6 +23,12 @@ public static class OriginSchema ValueType: AttrConstants.ATTR_SUBTYPE_STRING, Required: false, Description: "Optional dotted relationship path used to reach the source entity (e.g. 'Program.weeks')."), + + new AttrSchema( + Name: OriginConstants.ORIGIN_PASSTHROUGH_ATTR_CONVERT, + ValueType: AttrConstants.ATTR_SUBTYPE_BOOLEAN, + Required: false, + Description: "Acknowledges that this field's declared type deliberately differs from its @from source field's type (#185). Absent/false (the default), a passthrough is type-preserving — a differing field. or array-ness fails with ERR_PASSTHROUGH_TYPE_MISMATCH. Set true to opt out. This is an acknowledgement only: it does NOT generate a cast — the value flows through unchanged and the consumer owns any coercion. Real type-converting projections are origin.expression's job (#159)."), ]; private static readonly IReadOnlyList AggregateOriginAttrs = diff --git a/server/csharp/MetaObjects/SpecMetamodel/origin.json b/server/csharp/MetaObjects/SpecMetamodel/origin.json index 648a42ff8..9789221d6 100644 --- a/server/csharp/MetaObjects/SpecMetamodel/origin.json +++ b/server/csharp/MetaObjects/SpecMetamodel/origin.json @@ -13,7 +13,8 @@ "whenToUse": "A projection field just surfaces a field from a related entity. Declare the cross-entity passthrough instead of re-joining and re-selecting it by hand.", "children": [ { "type": "attr", "subType": "string", "name": "from", "min": 1, "max": 1, "description": "Dotted Entity.field reference identifying the source value this projection field passes through (e.g. 'Program.title')." }, - { "type": "attr", "subType": "string", "name": "via", "min": 0, "max": 1, "description": "Optional dotted relationship path used to reach the source entity (e.g. 'Program.weeks')." } + { "type": "attr", "subType": "string", "name": "via", "min": 0, "max": 1, "description": "Optional dotted relationship path used to reach the source entity (e.g. 'Program.weeks')." }, + { "type": "attr", "subType": "boolean", "name": "convert", "min": 0, "max": 1, "description": "Acknowledges that this field's declared type deliberately differs from its @from source field's type (#185). Absent/false (the default), a passthrough is type-preserving — a differing field. or array-ness fails with ERR_PASSTHROUGH_TYPE_MISMATCH. Set true to opt out. This is an acknowledgement only: it does NOT generate a cast — the value flows through unchanged and the consumer owns any coercion. Real type-converting projections are origin.expression's job (#159)." } ] }, { diff --git a/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java b/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java index be680fdba..5e7afc855 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java +++ b/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java @@ -197,6 +197,17 @@ public enum ErrorCode { */ ERR_EXTENDS_ORIGIN_MISMATCH, + /** + * #185: a field carrying {@code origin.passthrough @from: "Entity.field"} declares a + * {@code field.} or array-ness that differs from its resolved source field — + * a passthrough forwards the value unchanged, so the type must be identical. Host-agnostic + * (projections, entities, values, and the FR-015 stored-proc parameter refs the retired + * {@code ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH} used to cover). Nullability is NOT + * judged. Opt out of a deliberate type change with {@code @convert: true} (acknowledgement + * only — no generated cast). + */ + ERR_PASSTHROUGH_TYPE_MISMATCH, + /** * FR-024 (spec §7): an object.entity field carrying an origin.* child is derived * (read-only) and must be providable — the entity must declare at least one source @@ -300,8 +311,9 @@ public enum ErrorCode { /** FR-015: source.rdb @parameterRef set on a non-callable kind (table/view/materializedView). */ ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND, - /** FR-015: a parameter field's origin.passthrough @from references a field with a mismatched subtype. */ - ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH, + // #185: ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH RETIRED — the narrow FR-015 parameter-ref + // passthrough type check is generalized by the universal ERR_PASSTHROUGH_TYPE_MISMATCH (above), + // which validateOrigins already applies to every origin.passthrough (value hosts included). /** FR-014: object.entity @discriminator names a field that does not exist on the entity. */ ERR_DISCRIMINATOR_FIELD_NOT_FOUND, diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index ef79f2adb..a5fff31e9 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -1576,6 +1576,9 @@ private static void validateOriginNode(MetaRoot root, MetaObject obj, "origin.passthrough.@from", origin.getSource()); // FR-024 B6 — extends/origin agreement (host-agnostic; before via/inference). checkExtendsOriginAgreement(field, fromTarget.field, from, obj, origin.getSource()); + // #185 — passthrough is type-preserving unless @convert acknowledges a change + // (host-agnostic; runs whether @via is explicit, inferred, or a base column). + checkPassthroughType(field, fromTarget.field, from, origin.isConvert(), obj, origin.getSource()); String via = origin.getVia(); if (via != null && !via.isEmpty()) { java.util.List hops = @@ -2859,6 +2862,45 @@ private static void checkExtendsOriginAgreement(MetaField field, MetaField ResolvedSource.from(originSource, referrer, fromAttr)); } + /** + * #185 — passthrough is type-preserving. A field forwarding another field's value via + * {@code origin.passthrough} must declare the SAME {@code field.} and the same + * array-ness as its resolved {@code @from} source; differ → {@code ERR_PASSTHROUGH_TYPE_MISMATCH}. + * + *

Two axes: subtype ({@link MetaField#getSubType()}, intrinsic to the node) and array-ness + * ({@link MetaField#isArrayType()} — the RESOLVING/effective flag, ADR-0039, so a field inheriting + * its shape via {@code extends} is judged on its effective type; never the own-only + * {@code isArray()}). Nullability is deliberately NOT compared (a view over an outer join + * legitimately widens NOT NULL → nullable).

+ * + *

Escape hatch: {@code @convert: true} on the passthrough acknowledges a deliberate type + * change and suppresses the error (acknowledgement only — no generated cast). Host-agnostic + * (projections, entities, values, and the FR-015 stored-proc parameter refs the retired + * {@code ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH} used to cover). Envelope mirrors + * {@link #checkExtendsOriginAgreement} exactly (resolved: referrer = {@code host::field}, + * target = the raw {@code @from}).

+ */ + private static void checkPassthroughType(MetaField field, MetaField fromField, + String fromAttr, boolean convert, MetaObject obj, + com.metaobjects.source.ErrorSource originSource) { + if (convert) return; // deliberate type change acknowledged + // Compare both axes at once via the type-label: subtype names never contain + // "[]", so equal labels <=> same subType AND same array-ness. + String declared = "field." + field.getSubType() + (field.isArrayType() ? "[]" : ""); + String source = "field." + fromField.getSubType() + (fromField.isArrayType() ? "[]" : ""); + if (declared.equals(source)) return; + String referrer = obj.getShortName() + "::" + field.getName(); + throw new MetaDataException( + "ERR_PASSTHROUGH_TYPE_MISMATCH" + + ": origin.passthrough on " + obj.getName() + "." + field.getName() + + ": field is " + declared + " but its @from source \"" + fromAttr + + "\" is " + source + " — a passthrough forwards the value unchanged, so the " + + "types must match. Declare " + source + ", or set @convert: true to " + + "acknowledge a deliberate type change.", + ErrorCode.ERR_PASSTHROUGH_TYPE_MISMATCH, + ResolvedSource.from(originSource, referrer, fromAttr)); + } + // ========================================================================= // FR-024 B3/B4a — subtype rules: identity-name-required, value purity, // projection licensing. Mirrors TS subtype-rules.ts / the Python port. @@ -3303,8 +3345,10 @@ private static String ownAttrString(MetaData node, String attr) { // ========================================================================= // FR-015 — source.rdb @parameterRef typed-input rules. - // ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND / _UNRESOLVED / _NOT_VALUE_OBJECT / - // _PASSTHROUGH_TYPE_MISMATCH. Mirrors TS persistence/source/validate-source-parameter-ref.ts. + // ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND / _UNRESOLVED / _NOT_VALUE_OBJECT. + // Passthrough type-matching on parameter fields is the universal + // ERR_PASSTHROUGH_TYPE_MISMATCH (retired the narrow parameter-ref code, #185). + // Mirrors TS persistence/source/validate-source-parameter-ref.ts. // ========================================================================= private static final Set CALLABLE_KINDS = Set.of( @@ -3363,47 +3407,11 @@ static void validateSourceParameterRef(MetaRoot root) { ErrorCode.ERR_PARAMETER_REF_NOT_VALUE_OBJECT, source.getSource()); } - // ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH per parameter field. - // ADR-0039: resolving — the parameter value-object's fields may be - // inherited via extends (TS reads target.children() resolving). - for (MetaField paramField : target.getChildren(MetaField.class, true)) { - MetaOrigin passthrough = null; - // ADR-0039: own — origin.* never inherits (ADR-0029); the origin - // child and its @from are read own (TS: paramField.ownChildren()). - for (MetaData c : paramField.getChildren(MetaData.class, false)) { - if (c instanceof MetaOrigin - && PassthroughOrigin.SUBTYPE_PASSTHROUGH.equals(c.getSubType())) { - passthrough = (MetaOrigin) c; - break; - } - } - if (passthrough == null) continue; - String from = passthrough.getFrom(); - if (from == null || from.isEmpty()) continue; - int dot = from.indexOf('.'); - if (dot < 0) continue; - String targetEntityName = from.substring(0, dot); - String targetFieldName = from.substring(dot + 1); - MetaObject targetEntity = index.get(targetEntityName); - if (targetEntity == null) continue; - MetaField targetField = null; - // ADR-0039: resolving — the referenced entity's field may be - // inherited via extends (TS reads targetEntity.children() resolving). - for (MetaField f : targetEntity.getChildren(MetaField.class, true)) { - if (targetFieldName.equals(shortNameOf(f))) { targetField = f; break; } - } - if (targetField == null) continue; - if (!paramField.getSubType().equals(targetField.getSubType())) { - throw new MetaDataException( - "ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH" - + ": parameter field \"" + shortNameOf(paramField) + "\" (field." - + paramField.getSubType() + ") on @parameterRef \"" + ref - + "\" uses origin.passthrough @from: \"" + from + "\", but " - + shortNameOf(targetEntity) + "." + targetFieldName + " is field." - + targetField.getSubType() + "; types must match", - ErrorCode.ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH, paramField.getSource()); - } - } + // #185 — the per-parameter-field passthrough type check (formerly + // ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH here) is RETIRED: it is + // generalized by the universal ERR_PASSTHROUGH_TYPE_MISMATCH, which + // validateOrigins already applies to every origin.passthrough — including + // the object.value parameter-shape fields resolved via @parameterRef. } } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/origin/MetaOrigin.java b/server/java/metadata/src/main/java/com/metaobjects/origin/MetaOrigin.java index 62e42fb2b..e44948ca6 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/origin/MetaOrigin.java +++ b/server/java/metadata/src/main/java/com/metaobjects/origin/MetaOrigin.java @@ -70,6 +70,18 @@ public abstract class MetaOrigin extends MetaData { */ public static final String ATTR_OF = "of"; + /** + * #185 — boolean acknowledgement that a passthrough field's declared type + * deliberately differs from its {@code @from} source field's type. Optional on + * {@code origin.passthrough}. Absent/false (the default) ⇒ the passthrough is + * type-preserving, so a differing {@code field.} or array-ness fails + * with {@code ERR_PASSTHROUGH_TYPE_MISMATCH}. Set true to opt out — an + * acknowledgement ONLY: it does not generate a cast (the value flows through + * unchanged and the consumer owns any coercion; real type-converting + * projections are {@code origin.expression}'s job, #159). + */ + public static final String ATTR_CONVERT = "convert"; + // === AGGREGATE FUNCTION VOCABULARY === public static final String AGG_COUNT = "count"; @@ -154,6 +166,18 @@ public String getOf() { : null; } + /** + * #185 — true iff {@code @convert} is present and true (deliberate type-change + * acknowledgement on a {@code origin.passthrough}). Accepts a native boolean or + * the string {@code "true"}, mirroring the tolerant boolean-attr reads elsewhere + * in the loader. + */ + public boolean isConvert() { + // Mirrors MetaRelationship.isSymmetric() — the model-layer boolean-attr idiom. + return hasMetaAttr(ATTR_CONVERT) + && Boolean.parseBoolean(getMetaAttr(ATTR_CONVERT).getValueAsString()); + } + @Override public String toString() { return String.format("%s[%s:%s]{from=%s, via=%s, agg=%s, of=%s}", diff --git a/server/java/metadata/src/main/java/com/metaobjects/origin/PassthroughOrigin.java b/server/java/metadata/src/main/java/com/metaobjects/origin/PassthroughOrigin.java index 48801d65b..c850de91a 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/origin/PassthroughOrigin.java +++ b/server/java/metadata/src/main/java/com/metaobjects/origin/PassthroughOrigin.java @@ -1,5 +1,6 @@ package com.metaobjects.origin; +import com.metaobjects.attr.BooleanAttribute; import com.metaobjects.attr.StringAttribute; import com.metaobjects.registry.MetaDataRegistry; @@ -49,6 +50,12 @@ public static void registerTypes(MetaDataRegistry registry) { .ofType(StringAttribute.SUBTYPE_STRING).asSingle(); def.optionalAttributeWithConstraints(ATTR_VIA) .ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + // #185: @convert (optional boolean) — acknowledges a deliberate type + // change so passthrough type-preservation is not enforced. Registered + // here so the registry-conformance manifest carries it on the + // passthrough subtype (description sourced from spec/metamodel/origin.json). + def.optionalAttributeWithConstraints(ATTR_CONVERT) + .ofType(BooleanAttribute.SUBTYPE_BOOLEAN).asSingle(); }); } } diff --git a/server/python/src/metaobjects/core_types.py b/server/python/src/metaobjects/core_types.py index dfe7e4185..5de298aae 100644 --- a/server/python/src/metaobjects/core_types.py +++ b/server/python/src/metaobjects/core_types.py @@ -77,6 +77,7 @@ from .meta.persistence.origin.meta_origin import MetaOrigin from .meta.persistence.origin.origin_constants import ( ORIGIN_ATTR_AGG, + ORIGIN_ATTR_CONVERT, ORIGIN_ATTR_FROM, ORIGIN_ATTR_OF, ORIGIN_ATTR_VIA, @@ -595,7 +596,7 @@ def _register_subtypes( ) ) -# origin.passthrough — @from required, @via optional +# origin.passthrough — @from required, @via optional, @convert optional (#185). core_provider.add( TypeDefinition( type=TYPE_ORIGIN, @@ -604,6 +605,8 @@ def _register_subtypes( attrs=[ AttrSchema(name=ORIGIN_ATTR_FROM, value_type=ATTR_SUBTYPE_STRING, required=True), AttrSchema(name=ORIGIN_ATTR_VIA, value_type=ATTR_SUBTYPE_STRING, required=False), + # #185 — opt out of the type-preserving ERR_PASSTHROUGH_TYPE_MISMATCH check. + AttrSchema(name=ORIGIN_ATTR_CONVERT, value_type=ATTR_SUBTYPE_BOOLEAN, required=False), ], child_rules=[ChildRule(TYPE_ATTR, "*")], ) diff --git a/server/python/src/metaobjects/errors.py b/server/python/src/metaobjects/errors.py index b4d2eeeaa..16999e972 100644 --- a/server/python/src/metaobjects/errors.py +++ b/server/python/src/metaobjects/errors.py @@ -102,7 +102,13 @@ class ErrorCode(str, Enum): ERR_PARAMETER_REF_UNRESOLVED = "ERR_PARAMETER_REF_UNRESOLVED" ERR_PARAMETER_REF_NOT_VALUE_OBJECT = "ERR_PARAMETER_REF_NOT_VALUE_OBJECT" ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND = "ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND" - ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH = "ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH" + # #185 — a field forwarding another field's value via origin.passthrough must + # declare the SAME field. and array-ness as its resolved @from source + # (a passthrough forwards the value unchanged). A differing subType or array-ness + # → this error, unless @convert: true acknowledges a deliberate type change. This + # GENERALIZES and RETIRES the FR-015-narrow ERR_PARAMETER_REF_PASSTHROUGH_TYPE_ + # MISMATCH (the origin-paths pass runs over every object, value hosts included). + ERR_PASSTHROUGH_TYPE_MISMATCH = "ERR_PASSTHROUGH_TYPE_MISMATCH" # FR-014 — TPH discriminator cross-attribute validation. Cross-language # vocabulary; Python loader does not emit these yet. ERR_DISCRIMINATOR_FIELD_NOT_FOUND = "ERR_DISCRIMINATOR_FIELD_NOT_FOUND" diff --git a/server/python/src/metaobjects/loader/validate_source_parameter_ref.py b/server/python/src/metaobjects/loader/validate_source_parameter_ref.py index 1f98abc75..80dc18aff 100644 --- a/server/python/src/metaobjects/loader/validate_source_parameter_ref.py +++ b/server/python/src/metaobjects/loader/validate_source_parameter_ref.py @@ -8,9 +8,13 @@ object. * ``ERR_PARAMETER_REF_NOT_VALUE_OBJECT`` — ``@parameterRef`` points at an object that is not an ``object.value``. - * ``ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH`` — a parameter field uses - ``origin.passthrough @from: "Entity.field"`` but its subtype does not match - the referenced field's subtype. + +Note (#185): the parameter-field passthrough type-match check formerly emitted +here (``ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH``) is RETIRED. It is subsumed +by the universal ``ERR_PASSTHROUGH_TYPE_MISMATCH`` in ``validation_passes.py``, +whose origin-paths pass runs over every object — value hosts (parameter shapes) +included — so a parameter field's ``origin.passthrough`` type mismatch is now +caught there (with the ``@convert: true`` opt-out). Mirrors the TS reference ``packages/metadata/src/persistence/source/validate-source-parameter-ref.ts``. @@ -30,11 +34,7 @@ OBJECT_SUBTYPE_ENTITY, OBJECT_SUBTYPE_VALUE, ) -from ..meta.persistence.origin.origin_constants import ( - ORIGIN_ATTR_FROM, - ORIGIN_SUBTYPE_PASSTHROUGH, -) -from ..shared.base_types import TYPE_FIELD, TYPE_OBJECT, TYPE_ORIGIN, TYPE_SOURCE +from ..shared.base_types import TYPE_OBJECT, TYPE_SOURCE from ..shared.separators import PACKAGE_SEP _CALLABLE_KINDS = frozenset({SOURCE_KIND_STORED_PROC, SOURCE_KIND_TABLE_FUNCTION}) @@ -122,60 +122,7 @@ def validate_source_parameter_ref(root: MetaData, errors: list[MetaError]) -> No ) continue - # ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH — each parameter field - # with origin.passthrough must match the referenced field's subtype. - # ADR-0039: resolving — the parameter value-object's fields may be - # inherited via extends (mirrors the TS `target.children()` — - # validate-source-parameter-ref.ts:122). - for param_field in target.children(): - if param_field.type != TYPE_FIELD: - continue - # ADR-0039 own: origin.* never inherits (ADR-0029); the origin child - # is read own (mirrors the TS `paramField.ownChildren()`). - passthrough = next( - ( - c - for c in param_field.own_children() - if c.type == TYPE_ORIGIN - and c.sub_type == ORIGIN_SUBTYPE_PASSTHROUGH - ), - None, - ) - if passthrough is None: - continue - frm = passthrough.attr(ORIGIN_ATTR_FROM) # ADR-0039 sanctioned own: origin.* never inherits (ADR-0029) - if not isinstance(frm, str) or frm == "": - continue - dot = frm.find(".") - if dot < 0: - continue - target_entity_name = frm[:dot] - target_field_name = frm[dot + 1:] - target_entity = object_index.get(target_entity_name) - if target_entity is None: - continue # origin-paths pass surfaces this - # ADR-0039: resolving — the referenced entity's field may be - # inherited via extends (mirrors the TS `targetEntity.children()` — - # validate-source-parameter-ref.ts:139). - target_field = next( - ( - c - for c in target_entity.children() - if c.type == TYPE_FIELD and c.name == target_field_name - ), - None, - ) - if target_field is None: - continue - if param_field.sub_type != target_field.sub_type: - errors.append( - MetaError( - f'parameter field "{param_field.name}" ' - f"(field.{param_field.sub_type}) on @parameterRef " - f'"{ref}" uses origin.passthrough @from: "{frm}", but ' - f"{target_entity.name}.{target_field_name} is " - f"field.{target_field.sub_type}; types must match", - ErrorCode.ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH, - envelope=param_field.source, - ) - ) + # #185 — the parameter-field passthrough type-match check formerly here + # (ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH) is RETIRED; the universal + # ERR_PASSTHROUGH_TYPE_MISMATCH in validation_passes.py now covers it + # (its origin-paths pass runs over every object, value hosts included). diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index 936e03833..5e3dfcebf 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -78,6 +78,7 @@ LAYOUT_SUBTYPE_DATA_GRID, ) from ..meta.persistence.origin.origin_constants import ( + ORIGIN_ATTR_CONVERT, ORIGIN_ATTR_FROM, ORIGIN_ATTR_OF, ORIGIN_ATTR_VIA, @@ -1476,6 +1477,54 @@ def _check_extends_origin_agreement( ) +def _check_passthrough_type( + field: MetaData, + from_field: MetaData, + from_attr: str, + convert: bool, + obj: MetaData, + origin_source: object, + referrer: str, + errors: list[MetaError], +) -> None: + """#185 — a passthrough is type-preserving. A field forwarding another field's + value via origin.passthrough must declare the SAME field. and the same + array-ness as its resolved @from source — otherwise the projected type silently + diverges from its source (e.g. a field.uuid surfaced as field.string, forcing + hand-written String<->UUID bridging). + + Compares the RESOLVING/effective subType + isArray (ADR-0039), so a field + inheriting its shape via ``extends`` is judged on its effective type. Nullability + is deliberately NOT judged: a view over an outer join legitimately widens a NOT + NULL source column to nullable. + + Escape hatch: ``@convert: true`` on the origin.passthrough acknowledges a + deliberate type change and suppresses the error (it does NOT emit a cast — the + consumer owns any coercion; real converting projections are #159's + origin.expression). Host-agnostic (projections, entities, values, and the + FR-015 stored-proc parameter refs the retired ERR_PARAMETER_REF_PASSTHROUGH_ + TYPE_MISMATCH used to cover). Mirrors the TS reference ``_checkPassthroughType``. + """ + if convert: + return # deliberate type change acknowledged + # Compare both axes at once via the type-label: subtype names never contain + # "[]", so equal labels <=> same subType AND same array-ness. + declared = f"field.{field.sub_type}{'[]' if field.resolved_is_array() else ''}" + source = f"field.{from_field.sub_type}{'[]' if from_field.resolved_is_array() else ''}" + if declared == source: + return + errors.append( + MetaError( + f"origin.passthrough on {obj.name}.{field.name}: field is {declared} but its " + f"@from source '{from_attr}' is {source} — a passthrough forwards the value " + f"unchanged, so the types must match. Declare {source}, or set @convert: true " + f"to acknowledge a deliberate type change.", + ErrorCode.ERR_PASSTHROUGH_TYPE_MISMATCH, + envelope=resolved_source(origin_source, referrer, from_attr), + ) + ) + + def _validate_origin_paths( root: MetaData, errors: list[MetaError], @@ -1533,6 +1582,12 @@ def _validate_origin_paths( _check_extends_origin_agreement( node, from_target[1], from_ref, obj, origin.source, referrer, errors ) + # #185 — passthrough is type-preserving unless @convert acknowledges + # a change. ADR-0039 sanctioned own: origin.* never inherits (ADR-0029). + convert = origin.attr(ORIGIN_ATTR_CONVERT) is True + _check_passthrough_type( + node, from_target[1], from_ref, convert, obj, origin.source, referrer, errors + ) via = origin.attr(ORIGIN_ATTR_VIA) if isinstance(via, str) and via: hops = _validate_via_path(via, ctx, object_index, errors, origin, referrer) diff --git a/server/python/src/metaobjects/meta/persistence/origin/origin_constants.py b/server/python/src/metaobjects/meta/persistence/origin/origin_constants.py index e31faa0f3..4419faab4 100644 --- a/server/python/src/metaobjects/meta/persistence/origin/origin_constants.py +++ b/server/python/src/metaobjects/meta/persistence/origin/origin_constants.py @@ -14,6 +14,10 @@ # passthrough attrs ORIGIN_ATTR_FROM = "from" ORIGIN_ATTR_VIA = "via" +# #185 — acknowledges a deliberate type change on origin.passthrough (opt out of +# the type-preserving ERR_PASSTHROUGH_TYPE_MISMATCH check). Mirrors the TS +# ORIGIN_PASSTHROUGH_ATTR_CONVERT. +ORIGIN_ATTR_CONVERT = "convert" # aggregate attrs ORIGIN_ATTR_AGG = "agg" diff --git a/server/python/src/metaobjects/spec_metamodel/origin.json b/server/python/src/metaobjects/spec_metamodel/origin.json index 648a42ff8..9789221d6 100644 --- a/server/python/src/metaobjects/spec_metamodel/origin.json +++ b/server/python/src/metaobjects/spec_metamodel/origin.json @@ -13,7 +13,8 @@ "whenToUse": "A projection field just surfaces a field from a related entity. Declare the cross-entity passthrough instead of re-joining and re-selecting it by hand.", "children": [ { "type": "attr", "subType": "string", "name": "from", "min": 1, "max": 1, "description": "Dotted Entity.field reference identifying the source value this projection field passes through (e.g. 'Program.title')." }, - { "type": "attr", "subType": "string", "name": "via", "min": 0, "max": 1, "description": "Optional dotted relationship path used to reach the source entity (e.g. 'Program.weeks')." } + { "type": "attr", "subType": "string", "name": "via", "min": 0, "max": 1, "description": "Optional dotted relationship path used to reach the source entity (e.g. 'Program.weeks')." }, + { "type": "attr", "subType": "boolean", "name": "convert", "min": 0, "max": 1, "description": "Acknowledges that this field's declared type deliberately differs from its @from source field's type (#185). Absent/false (the default), a passthrough is type-preserving — a differing field. or array-ness fails with ERR_PASSTHROUGH_TYPE_MISMATCH. Set true to opt out. This is an acknowledgement only: it does NOT generate a cast — the value flows through unchanged and the consumer owns any coercion. Real type-converting projections are origin.expression's job (#159)." } ] }, { diff --git a/server/python/uv.lock b/server/python/uv.lock index 890b26fe0..1380c2d51 100644 --- a/server/python/uv.lock +++ b/server/python/uv.lock @@ -250,7 +250,7 @@ wheels = [ [[package]] name = "metaobjects" -version = "0.15.6" +version = "0.15.9" source = { editable = "." } dependencies = [ { name = "pyyaml" }, diff --git a/server/typescript/packages/metadata/src/core/index/index-definition.embedded.ts b/server/typescript/packages/metadata/src/core/index/index-definition.embedded.ts index 914bba75f..ad432faf0 100644 --- a/server/typescript/packages/metadata/src/core/index/index-definition.embedded.ts +++ b/server/typescript/packages/metadata/src/core/index/index-definition.embedded.ts @@ -1,4 +1,9 @@ -// Embedded definition for the index type (core vocab — physical attrs come from db provider). +// AUTO-GENERATED by scripts/generate-embedded-metamodel.ts — DO NOT EDIT. +// Canonical source: repo-root spec/metamodel/index.json +// Regenerate: bun run scripts/generate-embedded-metamodel.ts +// +// Embeds the canonical FR-033 ProviderDefinition so the provider can register +// itself wherever the on-disk spec/ tree is unavailable (bundled builds). import type { ProviderDefinition } from "../../provider-data.js"; export const INDEX_DEFINITION: ProviderDefinition = { diff --git a/server/typescript/packages/metadata/src/errors.ts b/server/typescript/packages/metadata/src/errors.ts index 681a96acd..28761ee1e 100644 --- a/server/typescript/packages/metadata/src/errors.ts +++ b/server/typescript/packages/metadata/src/errors.ts @@ -90,6 +90,14 @@ export const ERROR_CODES = [ // compute something new); a top-level abstract extends target is never // judged (shape-only reuse makes no lineage claim). "ERR_EXTENDS_ORIGIN_MISMATCH", + // #185 — a field carrying origin.passthrough @from declares a field. + // or array-ness differing from its resolved source field. A passthrough + // forwards the value unchanged, so its type must be identical. Host-agnostic + // (projections, entities, values, stored-proc parameter refs). Nullability is + // NOT judged (an outer-join view legitimately widens NOT NULL → nullable). + // Opt out of a deliberate type change with @convert: true (acknowledgement + // only — no generated cast; real conversion is origin.expression, #159). + "ERR_PASSTHROUGH_TYPE_MISMATCH", // FR-024 (spec §7) — an object.entity field carrying an origin.* child is // derived (read-only), so the entity must declare at least one source with // a read-only @kind (view/materializedView/storedProc/tableFunction) to @@ -131,7 +139,6 @@ export const ERROR_CODES = [ "ERR_PARAMETER_REF_UNRESOLVED", "ERR_PARAMETER_REF_NOT_VALUE_OBJECT", "ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND", - "ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH", // FR-014 — TPH discriminator cross-attribute validation. "ERR_DISCRIMINATOR_FIELD_NOT_FOUND", "ERR_DISCRIMINATOR_VALUE_DUPLICATE", diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts index 6cf4f870f..04d358a37 100644 --- a/server/typescript/packages/metadata/src/loader/validation-passes.ts +++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts @@ -90,6 +90,7 @@ import { ORIGIN_SUBTYPE_AGGREGATE, ORIGIN_PASSTHROUGH_ATTR_FROM, ORIGIN_PASSTHROUGH_ATTR_VIA, + ORIGIN_PASSTHROUGH_ATTR_CONVERT, ORIGIN_AGGREGATE_ATTR_OF, ORIGIN_AGGREGATE_ATTR_VIA, } from "../persistence/origin/origin-constants.js"; @@ -933,6 +934,55 @@ function _checkExtendsOriginAgreement( ); } +/** + * #185 — passthrough is type-preserving. A field forwarding another field's + * value via origin.passthrough must declare the SAME field. and the + * same array-ness as its resolved @from source — otherwise the projected type + * silently diverges from its source (e.g. a field.uuid surfaced as field.string, + * forcing hand-written String↔UUID bridging). Compares the RESOLVING/effective + * subType + isArray (ADR-0039), so a field inheriting its shape via `extends` + * is judged on its effective type. + * + * Nullability is deliberately NOT judged: a view over an outer join legitimately + * widens a NOT NULL source column to nullable, so a nullability check would + * false-positive on valid projections. + * + * Escape hatch: @convert: true on the origin.passthrough acknowledges a + * deliberate type change and suppresses the error (it does NOT emit a cast — + * the consumer owns any coercion; real converting projections are #159's + * origin.expression). Host-agnostic (projections, entities, values, and the + * FR-015 stored-proc parameter refs the retired ERR_PARAMETER_REF_PASSTHROUGH_ + * TYPE_MISMATCH used to cover). + */ +function _checkPassthroughType( + field: MetaData, + fromField: MetaData, + fromAttr: string, + convert: boolean, + obj: MetaData, + originSource: ErrorSource, + errors: ParseError[], +): void { + if (convert) return; // deliberate type change acknowledged + // Compare both axes at once via the type-label: subtype names never contain + // "[]", so equal labels ⇔ same subType AND same array-ness (nullability is + // deliberately not judged — an outer-join view legitimately widens NOT NULL). + const declared = `field.${field.subType}${field.resolvedIsArray() ? "[]" : ""}`; + const source = `field.${fromField.subType}${fromField.resolvedIsArray() ? "[]" : ""}`; + if (declared === source) return; + errors.push( + new ParseError( + `origin.passthrough on ${obj.name}.${field.name}: field is ${declared} but its @from source ` + + `"${fromAttr}" is ${source} — a passthrough forwards the value unchanged, so the types must ` + + `match. Declare ${source}, or set @convert: true to acknowledge a deliberate type change.`, + { + code: "ERR_PASSTHROUGH_TYPE_MISMATCH", + source: resolvedSource(originSource, `${obj.fqn()}::${field.name}`, fromAttr), + }, + ), + ); +} + export function validateOriginPaths(root: MetaData): ParseError[] { const errors: ParseError[] = []; // ADR-0039: root has no super; children()==ownChildren() but resolving is the default. @@ -965,6 +1015,10 @@ export function validateOriginPaths(root: MetaData): ParseError[] { // @via is explicit, inferred, or a base-relation column). if (fromTarget !== undefined) { _checkExtendsOriginAgreement(field, fromTarget.field, from, obj, origin.source, errors); + // #185 — passthrough is type-preserving unless @convert acknowledges a change. + // ADR-0039: own — origin.* never inherits (ADR-0029). + const convert = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_CONVERT) === true; + _checkPassthroughType(field, fromTarget.field, from, convert, obj, origin.source, errors); } // ADR-0039: own — origin.* never inherits (ADR-0029). const via = origin.ownAttr(ORIGIN_PASSTHROUGH_ATTR_VIA); diff --git a/server/typescript/packages/metadata/src/persistence/origin/origin-constants.ts b/server/typescript/packages/metadata/src/persistence/origin/origin-constants.ts index 43b13a042..b2dd3ea91 100644 --- a/server/typescript/packages/metadata/src/persistence/origin/origin-constants.ts +++ b/server/typescript/packages/metadata/src/persistence/origin/origin-constants.ts @@ -26,6 +26,10 @@ export type OriginSubType = (typeof ORIGIN_SUBTYPES)[number]; // passthrough attrs export const ORIGIN_PASSTHROUGH_ATTR_FROM = "from"; export const ORIGIN_PASSTHROUGH_ATTR_VIA = "via"; +// @convert (boolean): acknowledges a deliberate type change vs the @from source +// (#185). Suppresses ERR_PASSTHROUGH_TYPE_MISMATCH. Acknowledgement only — it +// does NOT generate a cast; real type-converting projections are origin.expression (#159). +export const ORIGIN_PASSTHROUGH_ATTR_CONVERT = "convert"; // collection attrs — a relationship-derived array of nested view-objects // (FR-004 R4). @via is the dotted relationship path (optionally wildcard- diff --git a/server/typescript/packages/metadata/src/persistence/origin/origin-definition.embedded.ts b/server/typescript/packages/metadata/src/persistence/origin/origin-definition.embedded.ts index 51925710e..85b32fcb7 100644 --- a/server/typescript/packages/metadata/src/persistence/origin/origin-definition.embedded.ts +++ b/server/typescript/packages/metadata/src/persistence/origin/origin-definition.embedded.ts @@ -35,6 +35,14 @@ export const ORIGIN_DEFINITION: ProviderDefinition = { "min": 0, "max": 1, "description": "Optional dotted relationship path used to reach the source entity (e.g. 'Program.weeks')." + }, + { + "type": "attr", + "subType": "boolean", + "name": "convert", + "min": 0, + "max": 1, + "description": "Acknowledges that this field's declared type deliberately differs from its @from source field's type (#185). Absent/false (the default), a passthrough is type-preserving — a differing field. or array-ness fails with ERR_PASSTHROUGH_TYPE_MISMATCH. Set true to opt out. This is an acknowledgement only: it does NOT generate a cast — the value flows through unchanged and the consumer owns any coercion. Real type-converting projections are origin.expression's job (#159)." } ] }, diff --git a/server/typescript/packages/metadata/src/persistence/source/validate-source-parameter-ref.ts b/server/typescript/packages/metadata/src/persistence/source/validate-source-parameter-ref.ts index 1b5b639e7..71c98a8a1 100644 --- a/server/typescript/packages/metadata/src/persistence/source/validate-source-parameter-ref.ts +++ b/server/typescript/packages/metadata/src/persistence/source/validate-source-parameter-ref.ts @@ -9,19 +9,16 @@ // "view" / "materializedView". Only the // callable kinds (storedProc, tableFunction) // accept parameters. -// ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH -// — a parameter field uses origin.passthrough -// @from: "Entity.field" but the parameter's -// subtype does not match the referenced -// field's subtype. +// +// Passthrough type-matching on parameter fields is NOT emitted here: it was +// retired (#185) into the universal ERR_PASSTHROUGH_TYPE_MISMATCH enforced in +// validateOriginPaths, which runs over parameter-ref value objects too. import type { MetaData } from "../../shared/meta-data.js"; import { ParseError } from "../../errors.js"; import { TYPE_OBJECT, TYPE_SOURCE, - TYPE_FIELD, - TYPE_ORIGIN, } from "../../shared/base-types.js"; import { OBJECT_SUBTYPE_VALUE, @@ -35,10 +32,6 @@ import { SOURCE_KIND_STORED_PROC, SOURCE_KIND_TABLE_FUNCTION, } from "./source-constants.js"; -import { - ORIGIN_SUBTYPE_PASSTHROUGH, - ORIGIN_PASSTHROUGH_ATTR_FROM, -} from "../../persistence/origin/origin-constants.js"; const CALLABLE_KINDS = new Set([ SOURCE_KIND_STORED_PROC, @@ -114,43 +107,14 @@ export function validateSourceParameterRef(root: MetaData): ParseError[] { continue; } - // ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH — every parameter field - // with origin.passthrough must have a subtype matching the referenced - // field. The origin path validation pass checks the from-path resolves; - // here we just check the subtype alignment. - // ADR-0039: resolving — the parameter value-object's fields may be inherited. - for (const paramField of target.children().filter((c) => c.type === TYPE_FIELD)) { - // ADR-0039: own — origin.* never inherits (ADR-0029); the origin child and - // its @from are read own. - const passthrough = paramField.ownChildren().find( - (c) => c.type === TYPE_ORIGIN && c.subType === ORIGIN_SUBTYPE_PASSTHROUGH, - ); - if (passthrough === undefined) continue; - // ADR-0039: own — origin.* never inherits (ADR-0029). - const from = passthrough.ownAttr(ORIGIN_PASSTHROUGH_ATTR_FROM); - if (typeof from !== "string" || from === "") continue; - const dot = from.indexOf("."); - if (dot < 0) continue; - const targetEntityName = from.slice(0, dot); - const targetFieldName = from.slice(dot + 1); - const targetEntity = objectIndex.get(targetEntityName); - if (targetEntity === undefined) continue; // origin-paths pass surfaces this - // ADR-0039: resolving — the referenced entity's field may be inherited via extends. - const targetField = targetEntity.children().find( - (c) => c.type === TYPE_FIELD && c.name === targetFieldName, - ); - if (targetField === undefined) continue; - if (paramField.subType !== targetField.subType) { - errors.push( - new ParseError( - `parameter field "${paramField.name}" (field.${paramField.subType}) on @parameterRef ` + - `"${ref}" uses origin.passthrough @from: "${from}", but ` + - `${targetEntity.name}.${targetFieldName} is field.${targetField.subType}; types must match`, - { code: "ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH", source: paramField.source }, - ), - ); - } - } + // #185 — passthrough type-preservation (parameter fields forwarding an + // entity field via origin.passthrough must match its type) is enforced + // UNIVERSALLY by _checkPassthroughType in validateOriginPaths (which runs + // over every object incl. these parameter-ref value-objects), emitting + // ERR_PASSTHROUGH_TYPE_MISMATCH. The narrow, subtype-only + // ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH that used to live here was + // retired in favour of that single invariant (which also gates array-ness + // and honours the @convert opt-out). } } diff --git a/server/typescript/packages/metadata/test/fr015-source-parameter-ref.test.ts b/server/typescript/packages/metadata/test/fr015-source-parameter-ref.test.ts index e5e0fba0f..fc396982f 100644 --- a/server/typescript/packages/metadata/test/fr015-source-parameter-ref.test.ts +++ b/server/typescript/packages/metadata/test/fr015-source-parameter-ref.test.ts @@ -1,6 +1,9 @@ // FR-015 — @parameterRef on source.rdb for typed callable inputs. // Loader-level tests: constant export, attr-schema registration, validation -// (UNRESOLVED, NOT_VALUE_OBJECT, ON_NON_CALLABLE_KIND, PASSTHROUGH_TYPE_MISMATCH). +// (UNRESOLVED, NOT_VALUE_OBJECT, ON_NON_CALLABLE_KIND). Passthrough type-match +// on parameter fields is now the universal ERR_PASSTHROUGH_TYPE_MISMATCH (#185), +// covered by the conformance corpus — the narrow ERR_PARAMETER_REF_PASSTHROUGH_ +// TYPE_MISMATCH was retired. import { describe, expect, test } from "bun:test"; import { SOURCE_ATTR_PARAMETER_REF } from "../src/persistence/source/source-constants.js"; @@ -32,8 +35,11 @@ describe("FR-015 constants + error codes", () => { expect(ERROR_CODES).toContain("ERR_PARAMETER_REF_ON_NON_CALLABLE_KIND"); }); - test("ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH registered", () => { - expect(ERROR_CODES).toContain("ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH"); + test("passthrough parameter type-match is the universal ERR_PASSTHROUGH_TYPE_MISMATCH (#185)", () => { + // The narrow, parameter-ref-only code was retired in favour of the single + // host-agnostic invariant enforced in validateOriginPaths. + expect(ERROR_CODES).toContain("ERR_PASSTHROUGH_TYPE_MISMATCH"); + expect(ERROR_CODES).not.toContain("ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH"); }); }); diff --git a/server/typescript/packages/metadata/test/origin-definition-completeness.test.ts b/server/typescript/packages/metadata/test/origin-definition-completeness.test.ts index e638af28d..74c6479cd 100644 --- a/server/typescript/packages/metadata/test/origin-definition-completeness.test.ts +++ b/server/typescript/packages/metadata/test/origin-definition-completeness.test.ts @@ -35,6 +35,9 @@ const EXPECTED: Record> = { passthrough: { from: { valueType: "string", required: true }, via: { valueType: "string", required: false }, + // #185 — @convert acknowledges a deliberate type change vs the @from source + // (suppresses ERR_PASSTHROUGH_TYPE_MISMATCH); optional, boolean. + convert: { valueType: "boolean", required: false }, }, aggregate: { agg: { diff --git a/spec/metamodel/origin.json b/spec/metamodel/origin.json index 648a42ff8..9789221d6 100644 --- a/spec/metamodel/origin.json +++ b/spec/metamodel/origin.json @@ -13,7 +13,8 @@ "whenToUse": "A projection field just surfaces a field from a related entity. Declare the cross-entity passthrough instead of re-joining and re-selecting it by hand.", "children": [ { "type": "attr", "subType": "string", "name": "from", "min": 1, "max": 1, "description": "Dotted Entity.field reference identifying the source value this projection field passes through (e.g. 'Program.title')." }, - { "type": "attr", "subType": "string", "name": "via", "min": 0, "max": 1, "description": "Optional dotted relationship path used to reach the source entity (e.g. 'Program.weeks')." } + { "type": "attr", "subType": "string", "name": "via", "min": 0, "max": 1, "description": "Optional dotted relationship path used to reach the source entity (e.g. 'Program.weeks')." }, + { "type": "attr", "subType": "boolean", "name": "convert", "min": 0, "max": 1, "description": "Acknowledges that this field's declared type deliberately differs from its @from source field's type (#185). Absent/false (the default), a passthrough is type-preserving — a differing field. or array-ness fails with ERR_PASSTHROUGH_TYPE_MISMATCH. Set true to opt out. This is an acknowledgement only: it does NOT generate a cast — the value flows through unchanged and the consumer owns any coercion. Real type-converting projections are origin.expression's job (#159)." } ] }, { From 634bce0208e2ed9b37317ce305c523aa5ff75c37 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 8 Jul 2026 23:07:28 -0400 Subject: [PATCH 2/3] no-mistakes(document): docs: sync origin.passthrough type-preservation docs --- docs/1.0-readiness.md | 30 +++++++++++++++---------- docs/features/migrations/0.x-to-1.0.md | 22 +++++++++++++++++- docs/features/templates-and-payloads.md | 3 +++ spec/cross-language-porting-guide.md | 9 +++++++- 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/docs/1.0-readiness.md b/docs/1.0-readiness.md index fd0648707..e87584e06 100644 --- a/docs/1.0-readiness.md +++ b/docs/1.0-readiness.md @@ -44,8 +44,9 @@ Status legend: ✅ done · 🔶 in progress · ⬜ not started · ❓ **[RATIFY] - ✅ **B2. `0.x → 1.0` migration guide written** — [`docs/features/migrations/0.x-to-1.0.md`](features/migrations/0.x-to-1.0.md): version re-baseline + verify strict-default, jsonb parsed-value, timestamp - instant-default, `@dbColumnType` slim, `index.*`/`@unique` removal, deprecated-export - removal. FR-024 deferred → no declared-API vocab to migrate. + instant-default, `@dbColumnType` slim, `index.*`/`@unique` removal, passthrough + type-preservation (`@convert`), deprecated-export removal. FR-024 deferred → no + declared-API vocab to migrate. ## C. Metamodel freeze (the durable spine) @@ -57,12 +58,16 @@ Status legend: ✅ done · 🔶 in progress · ⬜ not started · ❓ **[RATIFY] timestamps + `@localTime`, the `@dbColumnType` slim, reverse-nav finders — and ADR-0037 now gives a durable framework for any future addition. Only A2 (FR-024) remains open. Audit the registry for anything still experimental. -- 🔶 **C3. The last breaking metamodel/wire move landed in `0.15.1`/`7.7.1`** — the - `index.*` type + `identity.secondary` key-purity (ADR-0040; `@unique` removed → - `ERR_UNKNOWN_ATTR`). It followed `0.15.0`/`7.7.0` (the metamodel-1.0 vocabulary - program + the ADR-0039 own-accessor fix) and `0.14.0`/`7.6.0` (verify strict-default, - jsonb parsed-value). `index.*` **reset the quiet-period clock** — the first - no-breaking coordinated release must come *after* `0.15.1`/`7.7.1` (see G3). +- 🔶 **C3. The last breaking metamodel/wire move landed in the `0.15.x` line** — + `origin.passthrough` is now type-preserving (#185; a divergent `field.` or + array-ness fails load with `ERR_PASSTHROUGH_TYPE_MISMATCH`, generalizing/retiring the + FR-015 `ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH`; opt out with the acknowledgement-only + `@convert` on `origin.passthrough`). It followed `0.15.1`/`7.7.1` — the `index.*` type + + `identity.secondary` key-purity (ADR-0040; `@unique` removed → `ERR_UNKNOWN_ATTR`) — which + followed `0.15.0`/`7.7.0` (the metamodel-1.0 vocabulary program + the ADR-0039 own-accessor + fix) and `0.14.0`/`7.6.0` (verify strict-default, jsonb parsed-value). The passthrough move + **reset the quiet-period clock** — the first no-breaking coordinated release must come + *after* it (see G3). - ✅ **C4. `metamodelVersion` marker SHIPPED** (PR #145, all 5 ports, `"0.9"`). A single **rolled-up** spec-version string the loader/**registry** exposes and every port emits, asserted by the conformance matrix — the artifact that lets packages version independently while @@ -138,14 +143,15 @@ Status legend: ✅ done · 🔶 in progress · ⬜ not started · ❓ **[RATIFY] `→8.0.0`, freeze the spec version at `Metamodel 1.0` (C4). Both forward; no backwards move on any registry. - ⬜ **G2. Remove the deprecated export** if A3=remove (one-time). -- ⬜ **G3. A quiet period** — at least one coordinated release after `0.15.1`/`7.7.1` - (the last breaking move, C3) with **no metamodel-breaking changes**, to prove the - rate has actually dropped. Requires A2/FR-024 to land additively or defer. +- ⬜ **G3. A quiet period** — at least one coordinated release after the last breaking + move (C3, the `0.15.x` passthrough type-preservation move) with **no + metamodel-breaking changes**, to prove the rate has actually dropped. Requires + A2/FR-024 to land additively or defer. - ⬜ **G4. Cut Metamodel 1.0** through the `releasing` skill (RC → smoke → confirm → promote), with the migration guide (B2) + compat policy (B1) published alongside. --- -_Last updated 2026-07-02 (post `0.15.1`/`7.7.1` + `0.15.2`; A1 ratified — decouple). +_Last updated 2026-07-08 (post `0.15.1`/`7.7.1` + `0.15.2`; the `0.15.x` passthrough type-preservation move (#185) reset the quiet-period clock — C3/G3; A1 ratified — decouple). Owner: maintainer. Update the status marks as items land; ratify the remaining §A decisions (A2–A4) to unblock the rest._ diff --git a/docs/features/migrations/0.x-to-1.0.md b/docs/features/migrations/0.x-to-1.0.md index a0227a80e..34a36ead3 100644 --- a/docs/features/migrations/0.x-to-1.0.md +++ b/docs/features/migrations/0.x-to-1.0.md @@ -77,7 +77,27 @@ the new **`index.lookup`** type. - **Full mechanical rewrite + the no-DDL-churn guarantee:** [identity.secondary → index.lookup](identity-secondary-to-index-lookup.md). -## 6. Deprecated `codegen-ts/generators` export removed (at the 1.0 cut) +## 6. `origin.passthrough` is type-preserving (shipped 0.15.x) + +A field carrying `origin.passthrough @from: "Entity.field"` forwards the source value +unchanged, so its declared `field.` and array-ness must be **identical** to the +resolved source field. A divergence now fails load with `ERR_PASSTHROUGH_TYPE_MISMATCH` +(e.g. a `field.uuid` source surfaced by a projection as `field.string` — the exact +mismodeling that leaves a view `String`-typed over a `uuid` column and forces hand-written +`String↔UUID` coercion). The check compares **subType and array-ness only — nullability is +deliberately not judged** (a view over an outer join legitimately widens `NOT NULL` → +nullable). This generalizes and retires the narrow, stored-proc-parameter-ref-only +`ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH` (FR-015) into one host-agnostic invariant +covering projections, entities, values, and parameter refs alike. + +- **If load newly fails:** the error names both the declared type and the source type. + Declare the source's type (usually the fix — the projection was wrong), or set + `@convert: true` on the `origin.passthrough` if the divergence is intentional. +- **`@convert` is an acknowledgement only — it does NOT generate a cast**; the value flows + through unchanged and the consumer owns any coercion. Real type-converting projections + remain `origin.expression`'s job (#159, post-1.0). + +## 7. Deprecated `codegen-ts/generators` export removed (at the 1.0 cut) Importing the built-in generators from `@metaobjectsdev/codegen-ts/generators` (`entityFile` / `queriesFile` / `routesFile` / `barrel`) is **removed** at 1.0. diff --git a/docs/features/templates-and-payloads.md b/docs/features/templates-and-payloads.md index 5864d362b..06f2d9ae9 100644 --- a/docs/features/templates-and-payloads.md +++ b/docs/features/templates-and-payloads.md @@ -446,6 +446,9 @@ The following conformance fixtures gate this feature's behavior across ports: - [`fixtures/conformance/origin-collection-simple/`](../../fixtures/conformance/origin-collection-simple/) — `origin.collection` for repeated-row payloads - [`fixtures/conformance/error-origin-bad-via-path/`](../../fixtures/conformance/error-origin-bad-via-path/) — unresolvable `@via` rejected - [`fixtures/conformance/error-origin-bad-aggregate-fn/`](../../fixtures/conformance/error-origin-bad-aggregate-fn/) — unknown `@agg` rejected +- [`fixtures/conformance/error-origin-passthrough-type-mismatch/`](../../fixtures/conformance/error-origin-passthrough-type-mismatch/) — a `passthrough` field whose `field.` differs from its `@from` source fails with `ERR_PASSTHROUGH_TYPE_MISMATCH` +- [`fixtures/conformance/error-origin-passthrough-array-mismatch/`](../../fixtures/conformance/error-origin-passthrough-array-mismatch/) — a `passthrough` field whose array-ness differs from its `@from` source fails with `ERR_PASSTHROUGH_TYPE_MISMATCH` +- [`fixtures/conformance/origin-passthrough-convert-optout/`](../../fixtures/conformance/origin-passthrough-convert-optout/) — `@convert: true` acknowledges a deliberate type divergence (no cast generated) **Render engine output (`fixtures/render-conformance/`)** — byte-identical Mustache output across ports diff --git a/spec/cross-language-porting-guide.md b/spec/cross-language-porting-guide.md index e8229274e..4ecbf6985 100644 --- a/spec/cross-language-porting-guide.md +++ b/spec/cross-language-porting-guide.md @@ -74,7 +74,14 @@ warning lists, through these ordered stages. Mirror the order; the mechanism is `@db.indexed` → **warning** (drift detection). 4. **Origin paths** — `passthrough`/`aggregate` `@from`/`@of` must resolve to `Entity.field`; `@via` must hop correctly through relationships - (`ERR_INVALID_ORIGIN`). + (`ERR_INVALID_ORIGIN`). A `passthrough` field must also be **type-preserving**: + its declared `field.` and array-ness must match the resolved source field + (nullability is not judged — an outer-join view legitimately widens `NOT NULL` → + nullable). A divergence fails with `ERR_PASSTHROUGH_TYPE_MISMATCH` unless the + `origin.passthrough` carries `@convert: true` (an acknowledgement only — it does + not generate a cast). This host-agnostic check covers projections, entities, + values, and stored-proc parameter refs (it generalizes/retires the FR-015 + `ERR_PARAMETER_REF_PASSTHROUGH_TYPE_MISMATCH`). 5. **Attribute schema** — declared attrs must match their declared value type and allowed-values; required attrs must be present (effective: own + inherited). Undeclared attrs are **not** rejected (open policy). Errors `ERR_MISSING_REQUIRED_ATTR`, From bce56cff9f72585160227e23921fc8ed497ebd38 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 8 Jul 2026 23:15:22 -0400 Subject: [PATCH 3/3] no-mistakes(lint): lint: fix 4 unused Python imports; ports typecheck/compile clean --- server/python/src/metaobjects/core_types.py | 4 +--- server/python/src/metaobjects/loader/validation_passes.py | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/server/python/src/metaobjects/core_types.py b/server/python/src/metaobjects/core_types.py index 5de298aae..999b1bad3 100644 --- a/server/python/src/metaobjects/core_types.py +++ b/server/python/src/metaobjects/core_types.py @@ -35,7 +35,6 @@ ) from .meta.core.field.meta_field import MetaField from .meta.persistence.db.db_constants import ( - FIELD_ATTR_DB_COLUMN_TYPE, FIELD_ATTR_DB_INDEXED, ) from .meta.core.identity.identity_constants import ( @@ -49,7 +48,7 @@ IDENTITY_SUBTYPE_SECONDARY, ) from .meta.core.identity.meta_identity import MetaIdentity -from .meta.core.index.index_constants import INDEX_ATTR_FIELDS, INDEX_SUBTYPE_LOOKUP, INDEX_SUBTYPES +from .meta.core.index.index_constants import INDEX_ATTR_FIELDS, INDEX_SUBTYPES from .meta.core.index.meta_index import MetaIndex from .meta.core.object.meta_object import MetaObject from .meta.core.object.object_constants import ( @@ -57,7 +56,6 @@ OBJECT_ATTR_DISCRIMINATOR_VALUE, OBJECT_SUBTYPE_ENTITY, OBJECT_SUBTYPE_PROJECTION, - OBJECT_SUBTYPE_VALUE, OBJECT_SUBTYPES, ) from .meta.core.relationship.meta_relationship import MetaRelationship diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index 5e3dfcebf..83b0f1018 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -95,7 +95,6 @@ RELATIONSHIP_ATTR_THROUGH, ) from ..meta.core.identity.identity_constants import ( - IDENTITY_SUBTYPE_PRIMARY, IDENTITY_SUBTYPE_REFERENCE, IDENTITY_REFERENCE_ATTR_REFERENCES, )