Skip to content

Commit bb7eb33

Browse files
dmealingclaude
andcommitted
feat(metamodel): origin.passthrough is type-preserving unless @convert (#185)
A field carrying `origin.passthrough @from: "Entity.field"` forwards the source value unchanged, so its declared `field.<subType>` 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 <noreply@anthropic.com>
1 parent 2291ccc commit bb7eb33

46 files changed

Lines changed: 670 additions & 208 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Added
11+
- **`@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).
12+
13+
### Changed
14+
- **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.<subType>` 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.
15+
1016
## [0.15.15] — 2026-07-07
1117

1218
_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._

agent-context/skills/metaobjects-authoring/SKILL.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,18 @@ child (`source.rdb: { kind: view, table: v_author }`) — codegen keys projectio
601601
detection + view DDL off that read-only source, so without it `meta gen` emits
602602
nothing for the projection.
603603

604+
**A `passthrough` field must match its `@from` source's type.** A passthrough
605+
forwards the source value unchanged, so the projection field's `field.<subType>`
606+
and array-ness must be identical to the source field's — a `field.uuid` source
607+
declared as `field.string` on the projection fails load with
608+
`ERR_PASSTHROUGH_TYPE_MISMATCH` (this is exactly the mismodeling that leaves a
609+
view `String`-typed over a `uuid` column and forces hand-written coercion).
610+
Declare the source's type. If the type genuinely must differ on purpose, set
611+
`@convert: true` on the `origin.passthrough` to acknowledge it — an
612+
acknowledgement only, it does **not** generate a cast (you own any coercion).
613+
Nullability may differ (an outer-join view legitimately widens `NOT NULL` →
614+
nullable) — only subType + array-ness are checked.
615+
604616
## Abstracts + `extends` (deferred resolution) + `overlay`
605617

606618
An **abstract** node (`abstract: true`) describes a shape but is never emitted as

fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,18 @@ child (`source.rdb: { kind: view, table: v_author }`) — codegen keys projectio
601601
detection + view DDL off that read-only source, so without it `meta gen` emits
602602
nothing for the projection.
603603

604+
**A `passthrough` field must match its `@from` source's type.** A passthrough
605+
forwards the source value unchanged, so the projection field's `field.<subType>`
606+
and array-ness must be identical to the source field's — a `field.uuid` source
607+
declared as `field.string` on the projection fails load with
608+
`ERR_PASSTHROUGH_TYPE_MISMATCH` (this is exactly the mismodeling that leaves a
609+
view `String`-typed over a `uuid` column and forces hand-written coercion).
610+
Declare the source's type. If the type genuinely must differ on purpose, set
611+
`@convert: true` on the `origin.passthrough` to acknowledge it — an
612+
acknowledgement only, it does **not** generate a cast (you own any coercion).
613+
Nullability may differ (an outer-join view legitimately widens `NOT NULL` →
614+
nullable) — only subType + array-ness are checked.
615+
604616
## Abstracts + `extends` (deferred resolution) + `overlay`
605617

606618
An **abstract** node (`abstract: true`) describes a shape but is never emitted as

fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,18 @@ child (`source.rdb: { kind: view, table: v_author }`) — codegen keys projectio
601601
detection + view DDL off that read-only source, so without it `meta gen` emits
602602
nothing for the projection.
603603

604+
**A `passthrough` field must match its `@from` source's type.** A passthrough
605+
forwards the source value unchanged, so the projection field's `field.<subType>`
606+
and array-ness must be identical to the source field's — a `field.uuid` source
607+
declared as `field.string` on the projection fails load with
608+
`ERR_PASSTHROUGH_TYPE_MISMATCH` (this is exactly the mismodeling that leaves a
609+
view `String`-typed over a `uuid` column and forces hand-written coercion).
610+
Declare the source's type. If the type genuinely must differ on purpose, set
611+
`@convert: true` on the `origin.passthrough` to acknowledge it — an
612+
acknowledgement only, it does **not** generate a cast (you own any coercion).
613+
Nullability may differ (an outer-join view legitimately widens `NOT NULL` →
614+
nullable) — only subType + array-ness are checked.
615+
604616
## Abstracts + `extends` (deferred resolution) + `overlay`
605617

606618
An **abstract** node (`abstract: true`) describes a shape but is never emitted as

fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,18 @@ child (`source.rdb: { kind: view, table: v_author }`) — codegen keys projectio
601601
detection + view DDL off that read-only source, so without it `meta gen` emits
602602
nothing for the projection.
603603

604+
**A `passthrough` field must match its `@from` source's type.** A passthrough
605+
forwards the source value unchanged, so the projection field's `field.<subType>`
606+
and array-ness must be identical to the source field's — a `field.uuid` source
607+
declared as `field.string` on the projection fails load with
608+
`ERR_PASSTHROUGH_TYPE_MISMATCH` (this is exactly the mismodeling that leaves a
609+
view `String`-typed over a `uuid` column and forces hand-written coercion).
610+
Declare the source's type. If the type genuinely must differ on purpose, set
611+
`@convert: true` on the `origin.passthrough` to acknowledge it — an
612+
acknowledgement only, it does **not** generate a cast (you own any coercion).
613+
Nullability may differ (an outer-join view legitimately widens `NOT NULL` →
614+
nullable) — only subType + array-ness are checked.
615+
604616
## Abstracts + `extends` (deferred resolution) + `overlay`
605617

606618
An **abstract** node (`abstract: true`) describes a shape but is never emitted as

fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,18 @@ child (`source.rdb: { kind: view, table: v_author }`) — codegen keys projectio
601601
detection + view DDL off that read-only source, so without it `meta gen` emits
602602
nothing for the projection.
603603

604+
**A `passthrough` field must match its `@from` source's type.** A passthrough
605+
forwards the source value unchanged, so the projection field's `field.<subType>`
606+
and array-ness must be identical to the source field's — a `field.uuid` source
607+
declared as `field.string` on the projection fails load with
608+
`ERR_PASSTHROUGH_TYPE_MISMATCH` (this is exactly the mismodeling that leaves a
609+
view `String`-typed over a `uuid` column and forces hand-written coercion).
610+
Declare the source's type. If the type genuinely must differ on purpose, set
611+
`@convert: true` on the `origin.passthrough` to acknowledge it — an
612+
acknowledgement only, it does **not** generate a cast (you own any coercion).
613+
Nullability may differ (an outer-join view legitimately widens `NOT NULL` →
614+
nullable) — only subType + array-ness are checked.
615+
604616
## Abstracts + `extends` (deferred resolution) + `overlay`
605617

606618
An **abstract** node (`abstract: true`) describes a shape but is never emitted as

fixtures/conformance/ERROR-CODES.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"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).",
3333
"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.",
3434
"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).",
35+
"ERR_PASSTHROUGH_TYPE_MISMATCH": "#185: a field carrying origin.passthrough @from: \"Entity.field\" declares a field.<subType> 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).",
3536
"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).",
3637
"ERR_INVALID_TEMPLATE": "A template (prompt/output) declares a @payloadRef that does not resolve, or @requiredSlots that are not fields on its payload.",
3738
"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 @@
5455
"ERR_PARAMETER_REF_UNRESOLVED": "FR-015: source.rdb @parameterRef names an object that does not exist in the loaded model.",
5556
"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).",
5657
"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.",
57-
"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).",
5858
"ERR_DISCRIMINATOR_FIELD_NOT_FOUND": "FR-014: object.entity @discriminator names a field that does not exist on the entity (including inherited fields).",
5959
"ERR_DISCRIMINATOR_VALUE_DUPLICATE": "FR-014: two subtypes of the same @discriminator-bearing root declare the same @discriminatorValue.",
6060
"ERR_DISCRIMINATOR_VALUE_MISSING": "FR-014: a concrete entity extends a chain whose root declares @discriminator but the subtype lacks @discriminatorValue.",
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"errors": [
3+
{
4+
"code": "ERR_PASSTHROUGH_TYPE_MISMATCH",
5+
"source": {
6+
"format": "resolved",
7+
"files": [
8+
"meta.demo.json"
9+
],
10+
"jsonPath": "$['metadata.root'].children[1]['object.projection'].children[2]['field.string'].children[0]['origin.passthrough']",
11+
"referrer": "ProductView::tags",
12+
"target": "demo::Product.tags"
13+
}
14+
}
15+
],
16+
"warnings": []
17+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"metadata.root": {
3+
"package": "demo",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Product",
8+
"children": [
9+
{ "source.rdb": { "@table": "products" } },
10+
{ "field.uuid": { "name": "id" } },
11+
{ "field.string": { "name": "tags", "isArray": true } },
12+
{ "identity.primary": { "name": "id", "@fields": ["id"] } }
13+
]
14+
}
15+
},
16+
{
17+
"object.projection": {
18+
"name": "ProductView",
19+
"children": [
20+
{ "source.rdb": { "@kind": "view", "@view": "v_product" } },
21+
{ "field.uuid": { "name": "id", "extends": "demo::Product.id" } },
22+
{
23+
"field.string": {
24+
"name": "tags",
25+
"children": [
26+
{ "origin.passthrough": { "@from": "demo::Product.tags" } }
27+
]
28+
}
29+
},
30+
{ "identity.primary": { "name": "id", "extends": "demo::Product.id" } }
31+
]
32+
}
33+
}
34+
]
35+
}
36+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
["metaobjects-core-types", "metaobjects-db"]

0 commit comments

Comments
 (0)