|
| 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. |
0 commit comments