Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/features/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ YAML is desugared to canonical JSON at load time. See
| `identity.secondary` | Unique secondary index. | No. |
| `identity.reference` | Inbound FK from this entity to another. | No. |

> **`identity.primary` is a singleton — its name is optional.** An entity may carry
> at most one `identity.primary`; the loader names a name-less one `"primary"`
> automatically (so `{ "identity.primary": { "@fields": "id" } }` is the canonical
> minimal form — no need to invent a name). Declaring two primaries on one entity is
> an `ERR_TOO_MANY_OCCURRENCES` load error. `identity.secondary` / `identity.reference`
> are not singletons and DO require an explicit `name`.

## `extends:` for shared abstract bases

Common base fields (`id`, `createdAt`, `updatedAt`) live on an abstract entity that
Expand Down
81 changes: 81 additions & 0 deletions docs/superpowers/specs/2026-06-18-identity-default-name-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Config-driven default name for singleton child types (fix the load-breaking identity bug)

> Status: design (agreed). Written 2026-06-18. Fixes a real, widespread load-breaking bug:
> `identity.primary` requires an author-chosen `name` (FR-024), but the no-name form is what
> ~8 doc files, `docs/llms/llms-full.txt` (the LLM context), the metaobjects.dev homepage
> example, and most quickstarts show — so a user *or Claude* who copy-pastes the canonical
> example gets a model that **fails to load** (`ERR_IDENTITY_NAME_REQUIRED`).

## Problem (verified)

`server/typescript/packages/metadata/src/subtype-rules.ts` (FR-024 D2) errors when any
`identity.*` node has `name === ""`. The docs/website/llms-full teach exactly that form.
Reproduced: a minimal model with `identity.primary: { fields: id }` (no name) fails to load.

## Decision

Make the default **config-driven**, and only where it is unambiguous:

1. **Default-name applies only to singleton child types** — a type that can occur *at most
once per parent*. A static default name is collision-free **by construction** only for a
singleton; for multi-cardinality children (validators, views, secondary/reference
identities) a static default would collide.
2. **Cardinality is declared AND enforced in the provider JSON config.** Add a type-level
`maxOccurs` to the type definition (`spec/metamodel/*.json` → `TypeDef`). The loader
enforces it (error on more than `maxOccurs` of that `type.subType` under one parent).
3. **`defaultName` is honored only when `maxOccurs === 1`.** Add a type-level `defaultName`
to the config; the loader assigns it when the node's name is empty.
4. **No counter-append auto-naming.** The historical "append a number per new validator/view"
trick is rejected here: it produces *unpredictable* names that are hard to reference later
and would confuse an LLM trying to `extends`/address a node — which cuts against the
"LLM authors the model" thesis. Multi-cardinality children stay explicitly named.

`identity.primary` is the motivating case: exactly one per entity → `maxOccurs: 1`,
`defaultName: "primary"`. Stable and referenceable as `Entity.primary`.

## Changes

### Config (canonical source + embedded)
- `spec/metamodel/identity.json`: on `identity.primary`, add `"maxOccurs": 1`,
`"defaultName": "primary"`. Regenerate the embedded definitions
(`bun run scripts/generate-embedded-metamodel.ts`).
- `provider-data.ts` `TypeDef`: add optional `maxOccurs?: number` and `defaultName?: string`.
Thread both into the registry's type-definition record.

### Loader
- During load, **before** the FR-024 name check, for any node with `name === ""`: look up its
`type.subType` definition; if `maxOccurs === 1` and `defaultName` is set, assign the name.
- Add `maxOccurs` enforcement: count siblings of the same `type.subType` per parent; error
(`ERR_TOO_MANY_<TYPE>` / reuse an existing code) when the count exceeds `maxOccurs`.
- `subtype-rules.ts` FR-024 D2: only error when the name is still empty after the default pass
(so `identity.secondary`/`reference` — no `defaultName` — still require explicit names).

### Conformance
- Add a `fixtures/conformance/` fixture: an entity whose `identity.primary` omits the name →
loads, and the effective/canonical serialization shows `name: "primary"`. Plus a negative
fixture: two `identity.primary` under one entity → `maxOccurs` error.
- Add to `registry-conformance` if `maxOccurs`/`defaultName` surface in the manifest.

### Cross-port parity
The loader/subtype-rules exist per port (TS / Java / Python / C#). Ship TS as the reference,
then mirror `maxOccurs` + `defaultName` honoring in Java/Python/C# so the conformance fixture
passes on all five ports (the change is small and mechanical: read two config fields, apply +
enforce). The default vocabulary (`identity.primary → "primary"`) is identical everywhere.

### Sweep (separate, can land first)
Regardless of the engine change, fix the broken examples to a loading form (add explicit
`name:`): the ~8 feature docs, `docs/llms/llms-full.txt`, the `.claude` skills' examples, and
both website canonical examples. After the loader-default ships, the no-name form *also*
loads, but explicit-name docs stay correct and forward-compatible.

## Non-goals
- Counter-append auto-naming for validators/views (rejected — unpredictable, LLM-hostile).
- A dynamic name-generation strategy DSL. (Could revisit a deterministic `<field>`-scoped
default for field-nested singletons later; out of scope here.)

## Why this is the right call
It removes the single most common bit of YAML bloat (every entity's primary identity) while
keeping names **stable, predictable, and referenceable** — the property an LLM-authored model
depends on. The cardinality gate makes the default safe by construction, and putting both
`maxOccurs` and `defaultName` in the provider config keeps it declarative (ADR-0023), not a
hardcoded loader special-case.
1 change: 1 addition & 0 deletions fixtures/conformance/ERROR-CODES.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"ERR_UNRESOLVED_SUPER": "An extends/super reference names a node that does not exist.",
"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).",
"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.",
"ERR_TOO_MANY_OCCURRENCES": "A type.subType declared with maxOccurs (e.g. identity.primary, maxOccurs:1) appears more times than allowed under one parent.",
"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.",
"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.",
"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).",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"files": [
"meta.demo.json"
],
"jsonPath": "$['metadata.root'].children[0]['object.entity'].children[1]['identity.primary']"
"jsonPath": "$['metadata.root'].children[0]['object.entity'].children[3]['identity.secondary']"
}
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
"name": "Customer",
"children": [
{ "field.uuid": { "name": "id" } },
{ "identity.primary": { "@fields": ["id"] } }
{ "field.string": { "name": "email" } },
{ "identity.primary": { "name": "pk", "@fields": ["id"] } },
{ "identity.secondary": { "@fields": ["email"], "@unique": true } }
]
}
}
Expand Down
13 changes: 13 additions & 0 deletions fixtures/conformance/error-too-many-primary/expected-errors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"errors": [
{
"code": "ERR_TOO_MANY_OCCURRENCES",
"source": {
"format": "json",
"files": ["meta.demo.json"],
"jsonPath": "$['metadata.root'].children[0]['object.entity'].children[3]['identity.primary']"
}
}
],
"warnings": []
}
18 changes: 18 additions & 0 deletions fixtures/conformance/error-too-many-primary/input/meta.demo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"metadata.root": {
"package": "demo",
"children": [
{
"object.entity": {
"name": "Customer",
"children": [
{ "field.long": { "name": "id" } },
{ "field.long": { "name": "altId" } },
{ "identity.primary": { "name": "pk", "@fields": ["id"] } },
{ "identity.primary": { "name": "pk2", "@fields": ["altId"] } }
]
}
}
]
}
}
1 change: 1 addition & 0 deletions fixtures/conformance/error-too-many-primary/providers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["metaobjects-core-types"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"metadata.root": {
"package": "demo",
"children": [
{
"object.entity": {
"name": "Customer",
"children": [
{
"source.rdb": {
"@table": "customers"
}
},
{
"field.long": {
"name": "id"
}
},
{
"identity.primary": {
"name": "primary",
"@fields": [
"id"
],
"@generation": "increment"
}
}
]
}
}
]
}
}

34 changes: 34 additions & 0 deletions fixtures/conformance/identity-primary-default-name/expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"metadata.root": {
"package": "demo",
"children": [
{
"object.entity": {
"name": "Customer",
"children": [
{
"source.rdb": {
"@table": "customers"
}
},
{
"field.long": {
"name": "id"
}
},
{
"identity.primary": {
"name": "primary",
"@fields": [
"id"
],
"@generation": "increment"
}
}
]
}
}
]
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"metadata.root": {
"package": "demo",
"children": [
{
"object.entity": {
"name": "Customer",
"children": [
{ "source.rdb": { "@table": "customers" } },
{ "field.long": { "name": "id" } },
{ "identity.primary": { "@fields": ["id"], "@generation": "increment" } }
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["metaobjects-core-types", "metaobjects-db"]
18 changes: 15 additions & 3 deletions server/csharp/MetaObjects/CoreTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ private static TypeDefinition Def(
List<ChildRule> childRules,
Func<TypeId, string, MetaData> factory,
List<AttrSchema> attributes,
DataType? dataType = null)
DataType? dataType = null,
int maxOccurs = 0,
string? defaultName = null)
{
Func<TypeId, string, MetaData> wrappedFactory = (typeId, name) =>
{
Expand All @@ -78,7 +80,9 @@ private static TypeDefinition Def(
childRules: childRules,
factory: wrappedFactory,
attributes: attributes,
dataType: dataType);
dataType: dataType,
maxOccurs: maxOccurs,
defaultName: defaultName);
}

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -409,14 +413,22 @@ private static void RegisterCoreTypeDefs(TypeRegistry registry)
? ia
: [IdentitySchema.IdentityFieldsAttr];

// identity.primary is a singleton (exactly one per object): a name-less
// primary is named from config ("primary") and two primaries trip the
// maxOccurs enforcement pass (ERR_TOO_MANY_OCCURRENCES). Config-driven —
// declared on the type, not special-cased in the loader.
bool isPrimary = subType == IDENTITY_SUBTYPE_PRIMARY;

registry.Register(
Def(
TYPE_IDENTITY,
subType,
$"Identity ({subType})",
[Wildcard(TYPE_ATTR)],
nodeFactory,
idAttrs.ToList()));
idAttrs.ToList(),
maxOccurs: isPrimary ? 1 : 0,
defaultName: isPrimary ? subType : null));
}

// relationship — 4 subtypes (base + association + aggregation + composition)
Expand Down
3 changes: 3 additions & 0 deletions server/csharp/MetaObjects/Errors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ public enum ErrorCode
// Vocabulary-only here until FR-024 Phase E (the C# loader does not
// enforce these yet); the enum tracks the shared corpus codes.
ERR_IDENTITY_NAME_REQUIRED,
// A type.subType declared with maxOccurs (e.g. identity.primary, maxOccurs:1)
// appears more times than allowed under one parent.
ERR_TOO_MANY_OCCURRENCES,
ERR_PROJECTION_IDENTITY_NOT_EXTENDED,
ERR_IDENTITY_KEY_MISMATCH,
// FR-024 (ADR-0028): a source.* on an object.projection has a writable
Expand Down
4 changes: 4 additions & 0 deletions server/csharp/MetaObjects/Loader/MetaDataLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,10 @@ public LoadResult Load(IReadOnlyList<IMetaDataSource> sources)
errors.AddRange(attrResult.Errors);
warnings.AddRange(attrResult.Warnings);

// Pass 6b: generic singleton-cardinality (MaxOccurs) enforcement —
// ERR_TOO_MANY_OCCURRENCES (e.g. two identity.primary on one object).
errors.AddRange(ValidationPasses.ValidateMaxOccurs(root, _registry));

// Pass 7: dataGrid @filter value validation (field filterable + op allowed)
errors.AddRange(ValidationPasses.ValidateDataGridFilterValues(root));

Expand Down
46 changes: 46 additions & 0 deletions server/csharp/MetaObjects/Loader/ValidationPasses.cs
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,52 @@ public static AttrSchemaValidationResult ValidateAttrSchema(
return new AttrSchemaValidationResult(errors.AsReadOnly(), []);
}

// =========================================================================
// Pass 6b: ValidateMaxOccurs
// Generic singleton-cardinality enforcement. Per parent, tallies own children
// by (type, subType); the moment a group exceeds its registered MaxOccurs the
// OFFENDING child is reported with ERR_TOO_MANY_OCCURRENCES (envelope = that
// child's source, so the cross-port jsonPath points at it).
//
// MaxOccurs == 0 means unbounded (the default) — skipped. This is the generic
// enforcement backing the config-driven default-name rule: identity.primary
// (MaxOccurs == 1, DefaultName == "primary") is the first consumer.
// =========================================================================

public static IReadOnlyList<MetaError> ValidateMaxOccurs(MetaData root, TypeRegistry registry)
{
var errors = new List<MetaError>();
WalkMaxOccurs(root, registry, errors);
return errors;
}

private static void WalkMaxOccurs(MetaData node, TypeRegistry registry, List<MetaError> errors)
{
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var child in node.OwnChildren())
{
TypeDefinition? def = registry.Find(child.Type, child.SubType);
if (def is null || def.MaxOccurs < 1) continue; // 0 = unbounded
string key = $"{child.Type}.{child.SubType}";
counts.TryGetValue(key, out int seen);
seen++;
counts[key] = seen;
if (seen > def.MaxOccurs)
{
string head = node.Name != "" ? $"'{node.Name}' " : "";
errors.Add(new MetaError(
$"{head}declares more than {def.MaxOccurs} {key} " +
$"child{(def.MaxOccurs == 1 ? "" : "ren")}; at most {def.MaxOccurs} is allowed",
ErrorCode.ERR_TOO_MANY_OCCURRENCES,
Envelope: child.Source));
}
}
foreach (var child in node.OwnChildren())
{
WalkMaxOccurs(child, registry, errors);
}
}

// =========================================================================
// Pass 7: ValidateDataGridFilterValues
// - @filter over a non-@filterable field → ERR_BAD_ATTR_FILTER
Expand Down
11 changes: 11 additions & 0 deletions server/csharp/MetaObjects/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,17 @@ private static MetaData ParseNodeFresh(

// --- Create the model ---
TypeDefinition def = st.Registry.Find(type, subType)!;

// Config-driven default name for a singleton type: when the type definition
// declares MaxOccurs == 1 with a DefaultName, a name-less node is named from
// config and SERIALIZED with that name (e.g. identity.primary → "primary").
// Generic registry rule — not a per-type loader special-case. Two such
// singletons trip the MaxOccurs enforcement pass (ERR_TOO_MANY_OCCURRENCES).
if (name == "" && def.MaxOccurs == 1 && def.DefaultName is not null)
{
name = def.DefaultName;
}

MetaData model = def.Factory(def.TypeId, name);
// FR5a / ADR-0009: attach the JSON-source envelope at construction time.
model.SetSource(st.CurrentSource());
Expand Down
Loading
Loading