You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 0bdbffe
Browse filesBrowse the repository at this point in the historyBrowse files
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
Copy file name to clipboardExpand all lines: CHANGELOG.md
+44Lines changed: 44 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -352,6 +352,50 @@ command whose whole job is the drift verdict. Pinned by a regression test on the
352
352
which needs no Postgres container and covers the silent half: before the fix its failure
353
353
output *is* the bug, verbatim.
354
354
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
+
355
399
### Fixed — Java and Kotlin get the safety floor they never implemented (Maven)
356
400
357
401
`docs/features/codegen-concepts.md` §7 has always stated a **product-wide** backstop —
Copy file name to clipboardExpand all lines: fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
+25Lines changed: 25 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -789,6 +789,31 @@ declared field set IS the exposure (fail-closed). Give it a read-only `source.rd
789
789
projection detection + view DDL off that read-only source, so without it `meta gen`
790
790
emits nothing for the projection.
791
791
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).
Copy file name to clipboardExpand all lines: fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md
+25Lines changed: 25 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -789,6 +789,31 @@ declared field set IS the exposure (fail-closed). Give it a read-only `source.rd
789
789
projection detection + view DDL off that read-only source, so without it `meta gen`
790
790
emits nothing for the projection.
791
791
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).
0 commit comments