Skip to content

Commit 0bdbffe

Browse files
dmealingclaude
andcommitted
fix(loader): a projection may borrow any UNIQUE key, not only the primary (#310)
A projection borrows its key rather than declaring one — `identity.primary: { extends: "Account.pk" }`. The loader required a dotted extends target to have the same type AND subtype, so the borrowed identity had to be the entity's `identity.primary`. A read model keyed on a BUSINESS key — a unique code or slug modelled as `identity.secondary`, with the surrogate auto-increment primary deliberately never surfaced — could not be declared at all. It failed with ERR_EXTENDS_TARGET_MISMATCH plus a second, misleading ERR_MISSING_REQUIRED_ATTR for `@fields`, because the inherit never happened. THE CODE WAS STRICTER THAN THE CONTRACT IT SHIPS. The byte-gated registry manifest for `object.projection` says, in both `description` and `rules`: "Identity is optional and, when present, MUST extend an entity identity." Not "an entity's PRIMARY identity" — and an `identity.secondary` is an entity identity. `spec/metamodel/object.json` and ADR-0028 carry the same unqualified sentence. So this is a correction, and `expected-registry.json` is unchanged. THE RULE IS UNIQUENESS, NOT NOMINATION. ADR-0040 moved uniqueness into the TYPE, so primary and secondary are both unique keys, differing only in which one the entity nominated as its main handle; borrowing a key borrows uniqueness, not that nomination. `identity.reference` stays excluded on both sides — a foreign key is not unique, so it can never back a key — and that bound is pinned, not assumed. The subtype half of the gate was never written for identities. Its only conformance byte is `error-extends-entity-field-type-mismatch`, a `field.uuid` extending a `field.string`. For a FIELD, subtype IS the datatype and inheriting across it is incoherent; for an identity, subtype is a ROLE. A field-shape rule had been generalized onto a role axis without a fixture ever exercising it. Nothing downstream needed changing, which was checked rather than assumed: FR-024's key-correspondence pass never read the subtype, and the view builder already anchors the FROM relation on the extended identity's OWNER, so the emitted SQL selects the projected columns and omits the surrogate. TWO DOORS, ONE PREDICATE. TypeScript and C# each check this twice — eagerly during parse and again after all files load — and each held its own copy of the boolean. My first cut fixed only the deferred door and every probe passed, because the loader defers. They now share one function; reverting only the eager door turns the new test red. Gated by `projection-identity-borrows-secondary`, green in all four loader ports (TS 566 · Java 566 · C# 901 · Python 401), plus per-port negatives for the FK case and the untouched field case. The authoring skill showed only the trivial `extends: "Entity.id"` form, which is what led an adopter to conclude composite and natural-key projection identity were unsupported; it now shows both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhpswkF1NvwxhFWMmdAT15
1 parent 7da0ebd commit 0bdbffe

24 files changed

Lines changed: 656 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,50 @@ command whose whole job is the drift verdict. Pinned by a regression test on the
352352
which needs no Postgres container and covers the silent half: before the fix its failure
353353
output *is* the bug, verbatim.
354354

355+
### Fixed — a projection could not borrow an entity's alternate key ([#310](https://github.com/metaobjectsdev/metaobjects/issues/310), all four loaders)
356+
357+
A `object.projection` borrows its key rather than declaring one:
358+
`identity.primary: { extends: "Account.pk" }`. But the loader required a dotted `extends`
359+
target to have the same type **and subtype**, so the borrowed identity had to be the
360+
entity's `identity.primary`. A read model keyed on a **business key** — a unique code or
361+
slug the entity models as `identity.secondary`, with the surrogate auto-increment
362+
`identity.primary` deliberately never surfaced — failed to load with
363+
`ERR_EXTENDS_TARGET_MISMATCH`, plus a second, misleading `ERR_MISSING_REQUIRED_ATTR:
364+
@fields` (the inherit never happened, so the identity looked malformed too).
365+
366+
**This was the code being stricter than the contract it ships.** The byte-gated registry
367+
manifest for `object.projection` says, in both its `description` and its `rules`:
368+
369+
> Identity is optional and, when present, MUST extend **an entity identity**.
370+
371+
Not "an entity's PRIMARY identity" — and an `identity.secondary` is an entity identity.
372+
`spec/metamodel/object.json` and ADR-0028 carry the same unqualified sentence. So this is a
373+
correction, not a capability grant, and `expected-registry.json` is unchanged.
374+
375+
**The rule is uniqueness, not nomination.** ADR-0040 moved uniqueness into the TYPE, so
376+
`identity.primary` and `identity.secondary` are both unique keys — they differ only in
377+
which one the entity nominated as its main handle, and borrowing a key borrows uniqueness,
378+
not that nomination. `identity.reference` stays excluded on both sides: a foreign key is
379+
not unique, so it can never back a key. That bound is pinned per port, not assumed.
380+
381+
The subtype half of the gate was never written for identities in the first place. Its only
382+
conformance byte is `error-extends-entity-field-type-mismatch` — a `field.uuid` extending a
383+
`field.string`. For a FIELD, subtype IS the datatype and inheriting across it is incoherent;
384+
for an identity, subtype is a ROLE. A field-shape rule had been generalized onto a role axis
385+
without a fixture ever exercising it.
386+
387+
Nothing downstream needed changing: FR-024's key-correspondence pass never read the subtype,
388+
and the view builder already anchors the FROM relation on the *extended identity's owner*
389+
so the emitted SQL selects the projected columns and omits the surrogate, as intended.
390+
391+
**Two doors, one predicate.** TypeScript and C# each check this twice — an eager check
392+
during parse and a deferred one after all files load — and each held its own copy of the
393+
boolean. They now share one function, because a one-sided fix passes whichever path a given
394+
loader configuration happens to take; the eager door is pinned separately, and reverting
395+
only it turns the new test red.
396+
397+
New shared fixture `projection-identity-borrows-secondary`, green in all four ports.
398+
355399
### Fixed — Java and Kotlin get the safety floor they never implemented (Maven)
356400

357401
`docs/features/codegen-concepts.md` §7 has always stated a **product-wide** backstop —

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,31 @@ declared field set IS the exposure (fail-closed). Give it a read-only `source.rd
789789
projection detection + view DDL off that read-only source, so without it `meta gen`
790790
emits nothing for the projection.
791791

792+
**The borrowed key may be ANY unique key — including a composite, and including the
793+
entity's `identity.secondary`.** The single-field `extends: "Author.id"` above is the
794+
common case, not the limit. Both of these are legal:
795+
796+
```yaml
797+
# Composite primary → composite primary. @fields is COMPUTED from the local
798+
# pass-through fields, so it is optional here (declare it and it must agree).
799+
- identity.primary: { name: pk, extends: "Order.pk" } # Order.pk = [tenant, ref]
800+
801+
# A read model keyed on the entity's BUSINESS key, never surfacing its surrogate id.
802+
# Account has both: identity.primary pk (auto-increment id) AND identity.secondary
803+
# byCode (tenant + code). The view exposes tenant + code and borrows byCode.
804+
- identity.primary: { name: pk, extends: "Account.byCode" }
805+
```
806+
807+
The rule is **uniqueness, not nomination**: ADR-0040 put uniqueness in the type, so
808+
`identity.primary` and `identity.secondary` are both unique keys and either can back a
809+
projection's key. `identity.reference` cannot — a foreign key is not unique, so
810+
`identity.primary extends: "Account.ownerRef"` is `ERR_EXTENDS_TARGET_MISMATCH`.
811+
812+
Key correspondence still holds in every case: every field named by the borrowed identity's
813+
`@fields` needs a local field `extends`-ing that entity field, or the load fails with
814+
`ERR_IDENTITY_KEY_MISMATCH` — the identity cannot claim a pass-through the fields do not
815+
make.
816+
792817
**A CONCRETE projection declares its OWN source — never inherits one.** A
793818
projection may `extends` another projection to reuse shape, but the child must
794819
declare its own `source.rdb`; inheriting the parent's is

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,31 @@ declared field set IS the exposure (fail-closed). Give it a read-only `source.rd
789789
projection detection + view DDL off that read-only source, so without it `meta gen`
790790
emits nothing for the projection.
791791

792+
**The borrowed key may be ANY unique key — including a composite, and including the
793+
entity's `identity.secondary`.** The single-field `extends: "Author.id"` above is the
794+
common case, not the limit. Both of these are legal:
795+
796+
```yaml
797+
# Composite primary → composite primary. @fields is COMPUTED from the local
798+
# pass-through fields, so it is optional here (declare it and it must agree).
799+
- identity.primary: { name: pk, extends: "Order.pk" } # Order.pk = [tenant, ref]
800+
801+
# A read model keyed on the entity's BUSINESS key, never surfacing its surrogate id.
802+
# Account has both: identity.primary pk (auto-increment id) AND identity.secondary
803+
# byCode (tenant + code). The view exposes tenant + code and borrows byCode.
804+
- identity.primary: { name: pk, extends: "Account.byCode" }
805+
```
806+
807+
The rule is **uniqueness, not nomination**: ADR-0040 put uniqueness in the type, so
808+
`identity.primary` and `identity.secondary` are both unique keys and either can back a
809+
projection's key. `identity.reference` cannot — a foreign key is not unique, so
810+
`identity.primary extends: "Account.ownerRef"` is `ERR_EXTENDS_TARGET_MISMATCH`.
811+
812+
Key correspondence still holds in every case: every field named by the borrowed identity's
813+
`@fields` needs a local field `extends`-ing that entity field, or the load fails with
814+
`ERR_IDENTITY_KEY_MISMATCH` — the identity cannot claim a pass-through the fields do not
815+
make.
816+
792817
**A CONCRETE projection declares its OWN source — never inherits one.** A
793818
projection may `extends` another projection to reuse shape, but the child must
794819
declare its own `source.rdb`; inheriting the parent's is

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,31 @@ declared field set IS the exposure (fail-closed). Give it a read-only `source.rd
789789
projection detection + view DDL off that read-only source, so without it `meta gen`
790790
emits nothing for the projection.
791791

792+
**The borrowed key may be ANY unique key — including a composite, and including the
793+
entity's `identity.secondary`.** The single-field `extends: "Author.id"` above is the
794+
common case, not the limit. Both of these are legal:
795+
796+
```yaml
797+
# Composite primary → composite primary. @fields is COMPUTED from the local
798+
# pass-through fields, so it is optional here (declare it and it must agree).
799+
- identity.primary: { name: pk, extends: "Order.pk" } # Order.pk = [tenant, ref]
800+
801+
# A read model keyed on the entity's BUSINESS key, never surfacing its surrogate id.
802+
# Account has both: identity.primary pk (auto-increment id) AND identity.secondary
803+
# byCode (tenant + code). The view exposes tenant + code and borrows byCode.
804+
- identity.primary: { name: pk, extends: "Account.byCode" }
805+
```
806+
807+
The rule is **uniqueness, not nomination**: ADR-0040 put uniqueness in the type, so
808+
`identity.primary` and `identity.secondary` are both unique keys and either can back a
809+
projection's key. `identity.reference` cannot — a foreign key is not unique, so
810+
`identity.primary extends: "Account.ownerRef"` is `ERR_EXTENDS_TARGET_MISMATCH`.
811+
812+
Key correspondence still holds in every case: every field named by the borrowed identity's
813+
`@fields` needs a local field `extends`-ing that entity field, or the load fails with
814+
`ERR_IDENTITY_KEY_MISMATCH` — the identity cannot claim a pass-through the fields do not
815+
make.
816+
792817
**A CONCRETE projection declares its OWN source — never inherits one.** A
793818
projection may `extends` another projection to reuse shape, but the child must
794819
declare its own `source.rdb`; inheriting the parent's is

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,31 @@ declared field set IS the exposure (fail-closed). Give it a read-only `source.rd
789789
projection detection + view DDL off that read-only source, so without it `meta gen`
790790
emits nothing for the projection.
791791

792+
**The borrowed key may be ANY unique key — including a composite, and including the
793+
entity's `identity.secondary`.** The single-field `extends: "Author.id"` above is the
794+
common case, not the limit. Both of these are legal:
795+
796+
```yaml
797+
# Composite primary → composite primary. @fields is COMPUTED from the local
798+
# pass-through fields, so it is optional here (declare it and it must agree).
799+
- identity.primary: { name: pk, extends: "Order.pk" } # Order.pk = [tenant, ref]
800+
801+
# A read model keyed on the entity's BUSINESS key, never surfacing its surrogate id.
802+
# Account has both: identity.primary pk (auto-increment id) AND identity.secondary
803+
# byCode (tenant + code). The view exposes tenant + code and borrows byCode.
804+
- identity.primary: { name: pk, extends: "Account.byCode" }
805+
```
806+
807+
The rule is **uniqueness, not nomination**: ADR-0040 put uniqueness in the type, so
808+
`identity.primary` and `identity.secondary` are both unique keys and either can back a
809+
projection's key. `identity.reference` cannot — a foreign key is not unique, so
810+
`identity.primary extends: "Account.ownerRef"` is `ERR_EXTENDS_TARGET_MISMATCH`.
811+
812+
Key correspondence still holds in every case: every field named by the borrowed identity's
813+
`@fields` needs a local field `extends`-ing that entity field, or the load fails with
814+
`ERR_IDENTITY_KEY_MISMATCH` — the identity cannot claim a pass-through the fields do not
815+
make.
816+
792817
**A CONCRETE projection declares its OWN source — never inherits one.** A
793818
projection may `extends` another projection to reuse shape, but the child must
794819
declare its own `source.rdb`; inheriting the parent's is

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,31 @@ declared field set IS the exposure (fail-closed). Give it a read-only `source.rd
789789
projection detection + view DDL off that read-only source, so without it `meta gen`
790790
emits nothing for the projection.
791791

792+
**The borrowed key may be ANY unique key — including a composite, and including the
793+
entity's `identity.secondary`.** The single-field `extends: "Author.id"` above is the
794+
common case, not the limit. Both of these are legal:
795+
796+
```yaml
797+
# Composite primary → composite primary. @fields is COMPUTED from the local
798+
# pass-through fields, so it is optional here (declare it and it must agree).
799+
- identity.primary: { name: pk, extends: "Order.pk" } # Order.pk = [tenant, ref]
800+
801+
# A read model keyed on the entity's BUSINESS key, never surfacing its surrogate id.
802+
# Account has both: identity.primary pk (auto-increment id) AND identity.secondary
803+
# byCode (tenant + code). The view exposes tenant + code and borrows byCode.
804+
- identity.primary: { name: pk, extends: "Account.byCode" }
805+
```
806+
807+
The rule is **uniqueness, not nomination**: ADR-0040 put uniqueness in the type, so
808+
`identity.primary` and `identity.secondary` are both unique keys and either can back a
809+
projection's key. `identity.reference` cannot — a foreign key is not unique, so
810+
`identity.primary extends: "Account.ownerRef"` is `ERR_EXTENDS_TARGET_MISMATCH`.
811+
812+
Key correspondence still holds in every case: every field named by the borrowed identity's
813+
`@fields` needs a local field `extends`-ing that entity field, or the load fails with
814+
`ERR_IDENTITY_KEY_MISMATCH` — the identity cannot claim a pass-through the fields do not
815+
make.
816+
792817
**A CONCRETE projection declares its OWN source — never inherits one.** A
793818
projection may `extends` another projection to reuse shape, but the child must
794819
declare its own `source.rdb`; inheriting the parent's is

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,31 @@ declared field set IS the exposure (fail-closed). Give it a read-only `source.rd
789789
projection detection + view DDL off that read-only source, so without it `meta gen`
790790
emits nothing for the projection.
791791

792+
**The borrowed key may be ANY unique key — including a composite, and including the
793+
entity's `identity.secondary`.** The single-field `extends: "Author.id"` above is the
794+
common case, not the limit. Both of these are legal:
795+
796+
```yaml
797+
# Composite primary → composite primary. @fields is COMPUTED from the local
798+
# pass-through fields, so it is optional here (declare it and it must agree).
799+
- identity.primary: { name: pk, extends: "Order.pk" } # Order.pk = [tenant, ref]
800+
801+
# A read model keyed on the entity's BUSINESS key, never surfacing its surrogate id.
802+
# Account has both: identity.primary pk (auto-increment id) AND identity.secondary
803+
# byCode (tenant + code). The view exposes tenant + code and borrows byCode.
804+
- identity.primary: { name: pk, extends: "Account.byCode" }
805+
```
806+
807+
The rule is **uniqueness, not nomination**: ADR-0040 put uniqueness in the type, so
808+
`identity.primary` and `identity.secondary` are both unique keys and either can back a
809+
projection's key. `identity.reference` cannot — a foreign key is not unique, so
810+
`identity.primary extends: "Account.ownerRef"` is `ERR_EXTENDS_TARGET_MISMATCH`.
811+
812+
Key correspondence still holds in every case: every field named by the borrowed identity's
813+
`@fields` needs a local field `extends`-ing that entity field, or the load fails with
814+
`ERR_IDENTITY_KEY_MISMATCH` — the identity cannot claim a pass-through the fields do not
815+
make.
816+
792817
**A CONCRETE projection declares its OWN source — never inherits one.** A
793818
projection may `extends` another projection to reuse shape, but the child must
794819
declare its own `source.rdb`; inheriting the parent's is
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# projection-identity-borrows-secondary (#310)
2+
3+
A projection's `identity.primary` extends the entity's **`identity.secondary`**.
4+
5+
`Account` has a surrogate auto-increment `identity.primary` AND a unique business key
6+
(`byCode`, over `tenant` + `code`). The read model keys off the business key and
7+
**deliberately never surfaces the surrogate `id` at all** — the shape an adopter needs for a
8+
read-only API view addressed by a code or slug rather than an internal database id.
9+
10+
## Why this must load
11+
12+
The loader required a dotted `extends` target to have the same type *and subtype*, so this
13+
failed with `ERR_EXTENDS_TARGET_MISMATCH` — while the shipped, byte-gated registry text for
14+
`object.projection` says, in both its `description` and its `rules`:
15+
16+
> Identity is optional and, when present, MUST extend **an entity identity**.
17+
18+
Not "an entity's PRIMARY identity". An `identity.secondary` is an entity identity, so the
19+
loader was stricter than the contract it ships in every port.
20+
21+
**ADR-0040 is what makes the rule statable.** It moved uniqueness into the TYPE:
22+
`identity.primary` and `identity.secondary` are both unique keys (`@unique` was *removed*
23+
from secondary precisely because the subtype already says so), while `identity.reference` is
24+
a foreign key and carries no uniqueness. So a key may borrow any UNIQUE key — borrowing a
25+
key borrows uniqueness, not the entity's choice of which one is its main handle.
26+
27+
The bound matters as much as the grant: `identity.primary` extending an `identity.reference`
28+
is still refused, because an FK cannot back a key. That negative is pinned per-port rather
29+
than here, since this corpus's `expected-errors.json` shape covers one load at a time.
30+
31+
## Provenance of the rule this relaxes
32+
33+
The subtype half of the gate was never written for identities. Its only conformance byte is
34+
`error-extends-entity-field-type-mismatch` — a `field.uuid` extending a `field.string`. For a
35+
FIELD, subtype IS the datatype and inheriting across it is incoherent; for an identity,
36+
subtype is a ROLE. A field-shape rule was generalized onto a role axis without a fixture ever
37+
exercising it.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"metadata.root": {
3+
"package": "demo",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Account",
8+
"children": [
9+
{ "source.rdb": { "@table": "accounts" } },
10+
{ "field.long": { "name": "id" } },
11+
{ "field.string": { "name": "tenant" } },
12+
{ "field.string": { "name": "code" } },
13+
{ "field.string": { "name": "internalNotes" } },
14+
{ "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } },
15+
{ "identity.secondary": { "name": "byCode", "@fields": ["tenant", "code"] } }
16+
]
17+
}
18+
},
19+
{
20+
"object.projection": {
21+
"name": "AccountsByCode",
22+
"children": [
23+
{ "source.rdb": { "@kind": "view", "@table": "v_accounts_by_code" } },
24+
{ "field.string": { "name": "tenant", "extends": "demo::Account.tenant" } },
25+
{ "field.string": { "name": "code", "extends": "demo::Account.code" } },
26+
{ "identity.primary": { "name": "pk", "extends": "demo::Account.byCode" } }
27+
]
28+
}
29+
}
30+
]
31+
}
32+
}
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)