Skip to content

Commit 1a025c0

Browse files
authored
fix(metadata): config-driven default name for singleton identities (#44)
Config-driven `maxOccurs`/`defaultName` on the type registration so a name-less singleton `identity.primary` loads as `"primary"` (referenceable as `Entity.primary`), while multi-cardinality children still require explicit names. New `ERR_TOO_MANY_OCCURRENCES` on two primaries. Cross-port spec (`spec/metamodel/identity.json`); default-name enforcement TS-side per the existing FR-024 posture. Admin-merged: all substantive checks green on the head commit; the pull_request-only gates (release-gate/migrate-ts-pg/leak-scan) passed on the prior SHA and the only delta since was a non-null-assertion in one TS test file.
1 parent 9b9d52e commit 1a025c0

46 files changed

Lines changed: 680 additions & 24 deletions

Some content is hidden

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

docs/features/entities.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ YAML is desugared to canonical JSON at load time. See
7676
| `identity.secondary` | Unique secondary index. | No. |
7777
| `identity.reference` | Inbound FK from this entity to another. | No. |
7878

79+
> **`identity.primary` is a singleton — its name is optional.** An entity may carry
80+
> at most one `identity.primary`; the loader names a name-less one `"primary"`
81+
> automatically (so `{ "identity.primary": { "@fields": "id" } }` is the canonical
82+
> minimal form — no need to invent a name). Declaring two primaries on one entity is
83+
> an `ERR_TOO_MANY_OCCURRENCES` load error. `identity.secondary` / `identity.reference`
84+
> are not singletons and DO require an explicit `name`.
85+
7986
## `extends:` for shared abstract bases
8087

8188
Common base fields (`id`, `createdAt`, `updatedAt`) live on an abstract entity that
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Config-driven default name for singleton child types (fix the load-breaking identity bug)
2+
3+
> Status: design (agreed). Written 2026-06-18. Fixes a real, widespread load-breaking bug:
4+
> `identity.primary` requires an author-chosen `name` (FR-024), but the no-name form is what
5+
> ~8 doc files, `docs/llms/llms-full.txt` (the LLM context), the metaobjects.dev homepage
6+
> example, and most quickstarts show — so a user *or Claude* who copy-pastes the canonical
7+
> example gets a model that **fails to load** (`ERR_IDENTITY_NAME_REQUIRED`).
8+
9+
## Problem (verified)
10+
11+
`server/typescript/packages/metadata/src/subtype-rules.ts` (FR-024 D2) errors when any
12+
`identity.*` node has `name === ""`. The docs/website/llms-full teach exactly that form.
13+
Reproduced: a minimal model with `identity.primary: { fields: id }` (no name) fails to load.
14+
15+
## Decision
16+
17+
Make the default **config-driven**, and only where it is unambiguous:
18+
19+
1. **Default-name applies only to singleton child types** — a type that can occur *at most
20+
once per parent*. A static default name is collision-free **by construction** only for a
21+
singleton; for multi-cardinality children (validators, views, secondary/reference
22+
identities) a static default would collide.
23+
2. **Cardinality is declared AND enforced in the provider JSON config.** Add a type-level
24+
`maxOccurs` to the type definition (`spec/metamodel/*.json``TypeDef`). The loader
25+
enforces it (error on more than `maxOccurs` of that `type.subType` under one parent).
26+
3. **`defaultName` is honored only when `maxOccurs === 1`.** Add a type-level `defaultName`
27+
to the config; the loader assigns it when the node's name is empty.
28+
4. **No counter-append auto-naming.** The historical "append a number per new validator/view"
29+
trick is rejected here: it produces *unpredictable* names that are hard to reference later
30+
and would confuse an LLM trying to `extends`/address a node — which cuts against the
31+
"LLM authors the model" thesis. Multi-cardinality children stay explicitly named.
32+
33+
`identity.primary` is the motivating case: exactly one per entity → `maxOccurs: 1`,
34+
`defaultName: "primary"`. Stable and referenceable as `Entity.primary`.
35+
36+
## Changes
37+
38+
### Config (canonical source + embedded)
39+
- `spec/metamodel/identity.json`: on `identity.primary`, add `"maxOccurs": 1`,
40+
`"defaultName": "primary"`. Regenerate the embedded definitions
41+
(`bun run scripts/generate-embedded-metamodel.ts`).
42+
- `provider-data.ts` `TypeDef`: add optional `maxOccurs?: number` and `defaultName?: string`.
43+
Thread both into the registry's type-definition record.
44+
45+
### Loader
46+
- During load, **before** the FR-024 name check, for any node with `name === ""`: look up its
47+
`type.subType` definition; if `maxOccurs === 1` and `defaultName` is set, assign the name.
48+
- Add `maxOccurs` enforcement: count siblings of the same `type.subType` per parent; error
49+
(`ERR_TOO_MANY_<TYPE>` / reuse an existing code) when the count exceeds `maxOccurs`.
50+
- `subtype-rules.ts` FR-024 D2: only error when the name is still empty after the default pass
51+
(so `identity.secondary`/`reference` — no `defaultName` — still require explicit names).
52+
53+
### Conformance
54+
- Add a `fixtures/conformance/` fixture: an entity whose `identity.primary` omits the name →
55+
loads, and the effective/canonical serialization shows `name: "primary"`. Plus a negative
56+
fixture: two `identity.primary` under one entity → `maxOccurs` error.
57+
- Add to `registry-conformance` if `maxOccurs`/`defaultName` surface in the manifest.
58+
59+
### Cross-port parity
60+
The loader/subtype-rules exist per port (TS / Java / Python / C#). Ship TS as the reference,
61+
then mirror `maxOccurs` + `defaultName` honoring in Java/Python/C# so the conformance fixture
62+
passes on all five ports (the change is small and mechanical: read two config fields, apply +
63+
enforce). The default vocabulary (`identity.primary → "primary"`) is identical everywhere.
64+
65+
### Sweep (separate, can land first)
66+
Regardless of the engine change, fix the broken examples to a loading form (add explicit
67+
`name:`): the ~8 feature docs, `docs/llms/llms-full.txt`, the `.claude` skills' examples, and
68+
both website canonical examples. After the loader-default ships, the no-name form *also*
69+
loads, but explicit-name docs stay correct and forward-compatible.
70+
71+
## Non-goals
72+
- Counter-append auto-naming for validators/views (rejected — unpredictable, LLM-hostile).
73+
- A dynamic name-generation strategy DSL. (Could revisit a deterministic `<field>`-scoped
74+
default for field-nested singletons later; out of scope here.)
75+
76+
## Why this is the right call
77+
It removes the single most common bit of YAML bloat (every entity's primary identity) while
78+
keeping names **stable, predictable, and referenceable** — the property an LLM-authored model
79+
depends on. The cardinality gate makes the default safe by construction, and putting both
80+
`maxOccurs` and `defaultName` in the provider config keeps it declarative (ADR-0023), not a
81+
hardcoded loader special-case.

fixtures/conformance/ERROR-CODES.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"ERR_UNRESOLVED_SUPER": "An extends/super reference names a node that does not exist.",
1111
"ERR_EXTENDS_TARGET_MISMATCH": "FR-024: a dotted extends ref resolved to a node whose type or subtype does not match the extending node (a field may only extend a field of the same subtype; an identity only an identity).",
1212
"ERR_IDENTITY_NAME_REQUIRED": "FR-024: an identity.* node has no name. Identities are named, author-chosen (e.g. \"id\"), so the dotted by-name extends form can address them.",
13+
"ERR_TOO_MANY_OCCURRENCES": "A type.subType declared with maxOccurs (e.g. identity.primary, maxOccurs:1) appears more times than allowed under one parent.",
1314
"ERR_PROJECTION_IDENTITY_NOT_EXTENDED": "FR-024: an identity.* on an object.projection lacks extends — a projection identity is a pass-through of an entity identity.",
1415
"ERR_IDENTITY_KEY_MISMATCH": "FR-024: identity key correspondence broken — an extended-identity field has no local pass-through field extending it, or an explicit @fields disagrees with the computed pass-through key.",
1516
"ERR_PROJECTION_SOURCE_WRITABLE": "FR-024 (ADR-0028): a source.* on an object.projection has a writable @kind (table, or @kind omitted which defaults to table) — a projection is a derived read-only representation; its sources must be read-only kinds (view, materializedView, storedProc, tableFunction).",

fixtures/conformance/error-identity-name-required/expected-errors.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"files": [
88
"meta.demo.json"
99
],
10-
"jsonPath": "$['metadata.root'].children[0]['object.entity'].children[1]['identity.primary']"
10+
"jsonPath": "$['metadata.root'].children[0]['object.entity'].children[3]['identity.secondary']"
1111
}
1212
}
1313
],

fixtures/conformance/error-identity-name-required/input/meta.demo.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
"name": "Customer",
88
"children": [
99
{ "field.uuid": { "name": "id" } },
10-
{ "identity.primary": { "@fields": ["id"] } }
10+
{ "field.string": { "name": "email" } },
11+
{ "identity.primary": { "name": "pk", "@fields": ["id"] } },
12+
{ "identity.secondary": { "@fields": ["email"], "@unique": true } }
1113
]
1214
}
1315
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"errors": [
3+
{
4+
"code": "ERR_TOO_MANY_OCCURRENCES",
5+
"source": {
6+
"format": "json",
7+
"files": ["meta.demo.json"],
8+
"jsonPath": "$['metadata.root'].children[0]['object.entity'].children[3]['identity.primary']"
9+
}
10+
}
11+
],
12+
"warnings": []
13+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"metadata.root": {
3+
"package": "demo",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Customer",
8+
"children": [
9+
{ "field.long": { "name": "id" } },
10+
{ "field.long": { "name": "altId" } },
11+
{ "identity.primary": { "name": "pk", "@fields": ["id"] } },
12+
{ "identity.primary": { "name": "pk2", "@fields": ["altId"] } }
13+
]
14+
}
15+
}
16+
]
17+
}
18+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
["metaobjects-core-types"]
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"metadata.root": {
3+
"package": "demo",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Customer",
8+
"children": [
9+
{
10+
"source.rdb": {
11+
"@table": "customers"
12+
}
13+
},
14+
{
15+
"field.long": {
16+
"name": "id"
17+
}
18+
},
19+
{
20+
"identity.primary": {
21+
"name": "primary",
22+
"@fields": [
23+
"id"
24+
],
25+
"@generation": "increment"
26+
}
27+
}
28+
]
29+
}
30+
}
31+
]
32+
}
33+
}
34+
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"metadata.root": {
3+
"package": "demo",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Customer",
8+
"children": [
9+
{
10+
"source.rdb": {
11+
"@table": "customers"
12+
}
13+
},
14+
{
15+
"field.long": {
16+
"name": "id"
17+
}
18+
},
19+
{
20+
"identity.primary": {
21+
"name": "primary",
22+
"@fields": [
23+
"id"
24+
],
25+
"@generation": "increment"
26+
}
27+
}
28+
]
29+
}
30+
}
31+
]
32+
}
33+
}
34+

0 commit comments

Comments
 (0)