@metaobjectsdev/metadata 1.0.4 → 1.0.5-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/constants.d.ts +2 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +2 -0
  4. package/dist/constants.js.map +1 -1
  5. package/dist/core/relationship/derive-m2m-fields.d.ts.map +1 -1
  6. package/dist/core/relationship/derive-m2m-fields.js +50 -27
  7. package/dist/core/relationship/derive-m2m-fields.js.map +1 -1
  8. package/dist/core/requirement/requirement-constants.d.ts.map +1 -1
  9. package/dist/core/requirement/requirement-constants.js +4 -2
  10. package/dist/core/requirement/requirement-constants.js.map +1 -1
  11. package/dist/core/requirement/requirement-definition.embedded.js +2 -2
  12. package/dist/core/requirement/requirement-definition.embedded.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js +5 -0
  16. package/dist/errors.js.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/library/embedded-library.generated.js +1 -1
  22. package/dist/library/embedded-library.generated.js.map +1 -1
  23. package/dist/loader/meta-data-loader.d.ts.map +1 -1
  24. package/dist/loader/meta-data-loader.js +8 -1
  25. package/dist/loader/meta-data-loader.js.map +1 -1
  26. package/dist/loader/validation-passes.d.ts +1 -0
  27. package/dist/loader/validation-passes.d.ts.map +1 -1
  28. package/dist/loader/validation-passes.js +115 -0
  29. package/dist/loader/validation-passes.js.map +1 -1
  30. package/dist/parser-core.d.ts.map +1 -1
  31. package/dist/parser-core.js +70 -22
  32. package/dist/parser-core.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/constants.ts +2 -0
  35. package/src/core/relationship/derive-m2m-fields.ts +56 -27
  36. package/src/core/requirement/requirement-constants.ts +4 -2
  37. package/src/core/requirement/requirement-definition.embedded.ts +2 -2
  38. package/src/errors.ts +5 -0
  39. package/src/index.ts +1 -1
  40. package/src/library/embedded-library.generated.ts +1 -1
  41. package/src/loader/meta-data-loader.ts +9 -1
  42. package/src/loader/validation-passes.ts +113 -0
  43. package/src/parser-core.ts +81 -30
@@ -10,7 +10,7 @@ export const EMBEDDED_LIBRARY: Record<string, string> = {
10
10
  "ai/model": "# library/ai/model.yaml — the CORE layer: the LLM-call trace envelope.\n#\n# Adopters opt in via `libraries: [\"ai\"]`, then `extends: \"metaobjects::ai::LlmCallBase\"`.\n#\n# This layer declares NO `source.rdb`, so opting into `\"ai\"` alone adds zero tables and\n# zero generated code — the design is present and resolvable, and nothing else happens\n# until the adopter adds `\"ai/db\"`. See library/iam/model.yaml for the full rationale.\n#\n# This file was split out of the former `library/ai/llm-call.yaml`, which shipped the\n# abstract base and a concrete `LlmCall` carrying `source.rdb` together. That was\n# recorded as an accepted wart on the grounds that splitting would change what existing\n# `ai` adopters get; a sweep of the estate found there are none, so it was closed rather\n# than documented (FR-043 Amendment 1).\nmetadata:\n package: metaobjects::ai\n children:\n - object.entity:\n name: LlmCallBase\n abstract: true\n children:\n - field.uuid: { name: traceId }\n - field.uuid: { name: spanId }\n - field.uuid: { name: parentSpanId }\n - field.string: { name: sessionId }\n - field.string: { name: callType }\n - field.string: { name: system }\n - field.string: { name: requestModel }\n - field.string: { name: responseModel }\n - field.int: { name: inputTokens }\n - field.int: { name: outputTokens }\n - field.currency: { name: costMinor, currency: USD }\n - field.int: { name: latencyMs }\n - field.string: { name: finishReason }\n - field.string: { name: status }\n - field.string: { name: errorDetail }\n - field.timestamp: { name: startedAt }\n - field.string: { name: llmRequest, dbColumnType: jsonb } # generic jsonb (no objectRef)\n - field.string: { name: llmResponse, dbColumnType: jsonb }\n - object.entity:\n name: LlmCall\n extends: metaobjects::ai::LlmCallBase\n description: The concrete trace row. Its `source.rdb` lives in db.yaml, so opting into the core layer alone declares the shape without proposing a table.\n children:\n - identity.primary: { name: id, fields: [\"spanId\"] }\n",
11
11
  "ai/requirements": "# library/ai/requirements.yaml — what the LLM-call trace envelope PROMISES.\n#\n# A RETROFIT, not new design: llm-call.yaml landed 2026-06-03 and `requirement.functional`\n# first appears 2026-08-11, so the library could not have carried requirements when it was\n# written. That is why this file is worth reading as a worked example — it shows what\n# declaring the design of something that already exists actually turns up.\n#\n# The entry that earns its keep is `typedIo`, honestly `partial` + `accepted`: the library\n# declares the ENVELOPE and the adopter declares the typed VO columns. Recording that seam\n# in the ledger is where an agent meets it, before adding a fourth trace column.\n#\n# HIERARCHY IS NESTING, and the L4/L5 split is grain. An L4 names the OBJECT it is about;\n# the fields that carry it hang off it as an L5 child. Writing the fields at L4 is\n# ERR_REQUIREMENT_L4_NOT_OBJECT, and writing the concerns as SIBLINGS of the L2 leaves the\n# L2 claiming nothing — both of which this file did until the standalone verify gate\n# existed (`cli/test/shipped-library-verify.test.ts`).\nmetadata:\n package: metaobjects::ai\n children:\n - requirement.functional:\n name: llmTracing\n level: 2\n status: live\n statement: Every call to a language model leaves a row that says what was asked, what came back, what it cost and how long it took.\n counterexample: A spend figure nobody can attribute to a call.\n description: The segment this library covers. Its three children below are the concerns it decomposes into.\n children:\n - requirement.functional:\n name: envelope\n level: 4\n status: live\n statement: A trace row identifies its call and its place in a trace — trace, span, parent span, session, call type, system.\n counterexample: A log line that cannot be joined to the request that produced it.\n implementedBy: [LlmCallBase]\n children:\n - requirement.functional:\n name: traceAddressing\n level: 5\n status: live\n statement: The four addressing columns are declared on the base — trace, span, parent span and session.\n counterexample: A row whose place in a trace is inferred from insertion order.\n description: >-\n The member grain exists here so the claim RESOLVES against the fields\n themselves: renaming or dropping one of them dangles this reference and\n fails the build, which naming the object alone would not.\n implementedBy: [LlmCallBase.traceId, LlmCallBase.spanId, LlmCallBase.parentSpanId, LlmCallBase.sessionId]\n\n - requirement.functional:\n name: accounting\n level: 4\n status: live\n statement: A trace row carries the tokens in, the tokens out, and the cost in integer minor units.\n counterexample: A cost stored as a float.\n description: >-\n `field.currency` — integer minor units on the wire, always. Float arithmetic for\n money is forbidden by the cross-port wire contract, and a spend total is exactly\n the sum that exposes it.\n implementedBy: [LlmCallBase]\n children:\n - requirement.functional:\n name: tokenAndCostColumns\n level: 5\n status: live\n statement: Tokens in, tokens out and cost are three declared columns, the cost a field.currency.\n counterexample: A cost column declared as a double.\n implementedBy: [LlmCallBase.inputTokens, LlmCallBase.outputTokens, LlmCallBase.costMinor]\n\n - requirement.functional:\n name: typedIo\n level: 4\n status: partial\n disposition: accepted\n statement: The request and response bodies are stored as structured jsonb, not as opaque text.\n counterexample: A prompt stored as a string nobody can query a field out of.\n notes: >-\n The library declares the two columns as generic jsonb with no `@objectRef`,\n because it cannot know the adopter's request/response shape. Typing them is the\n ADOPTER's move: declare an `object.value` and overlay the field with\n `@objectRef` + `@storage: jsonb`. This is the seam ADR-0024 drew, recorded here\n rather than in prose so it is in the ledger an agent reads before adding a\n fourth trace column of its own.\n implementedBy: [LlmCallBase]\n children:\n - requirement.functional:\n name: jsonbBodies\n level: 5\n status: live\n statement: The request and response bodies are declared as jsonb columns on the base.\n counterexample: A prompt stored in a text column.\n description: >-\n `live` where its parent is `partial`, and the split is the point: the\n COLUMNS are shipped and this claim is fully realised; what is outstanding\n is the TYPING of them, which is the parent's gap and the adopter's move.\n implementedBy: [LlmCallBase.llmRequest, LlmCallBase.llmResponse]\n\n - requirement.architectural:\n name: traceRowsCarryTiming\n status: live\n statement: Every trace row records when the call started and how long it took.\n counterexample: A latency figure derived from log timestamps after the fact.\n description: >-\n Architectural, so it propagates down `extends` to every adopter entity deriving\n from LlmCallBase — which is the point: an adopter's own trace table is claimed\n by this requirement for free, and dropping the columns breaks the build.\n implementedBy: [LlmCallBase]\n\n - requirement.architectural:\n name: traceRowsCarryOutcome\n status: live\n statement: Every trace row records how the call ended — a status, a finish reason, and the error detail when there was one.\n counterexample: A failed call indistinguishable from one that never happened.\n implementedBy: [LlmCallBase]\n",
12
12
  "iam/db": "# library/iam/db.yaml — the DB PERSISTENCE layer for metaobjects::iam.\n#\n# Opted into as `\"iam/db\"`, which IMPLIES `\"iam\"`: this file is nothing but\n# `overlay: true` redeclarations, and an overlay whose target was never declared is\n# ERR_OVERLAY_NO_TARGET.\n#\n# It carries exactly two kinds of child — `source.rdb` and `index.lookup` — and nothing\n# else. The field set, the identities and the relationships all live in model.yaml,\n# because they are the DESIGN; what lives here is where the rows go and which lookups are\n# worth an index. Add a field here and the core layer stops being the whole model, which\n# is the thing the split exists to guarantee.\n#\n# Physical names are `iam_`-prefixed. Two reasons, both real: `user` and `group` are\n# reserved words in Postgres, and an adopter very likely has tables of their own by those\n# names. A library that collides on a table name is a library nobody can adopt.\nmetadata:\n package: metaobjects::iam\n children:\n - object.entity:\n name: User\n overlay: true\n children:\n - source.rdb: { table: iam_user, role: primary }\n\n - object.entity:\n name: GroupType\n overlay: true\n children:\n - source.rdb: { table: iam_group_type, role: primary }\n\n - object.entity:\n name: Group\n overlay: true\n children:\n - source.rdb: { table: iam_group, role: primary }\n # Nesting is walked parent-ward constantly; the FK alone gives no index.\n - index.lookup: { name: ixParent, fields: [parentId] }\n\n - object.entity:\n name: Role\n overlay: true\n children:\n - source.rdb: { table: iam_role, role: primary }\n\n - object.entity:\n name: Permission\n overlay: true\n children:\n - source.rdb: { table: iam_permission, role: primary }\n\n - object.entity:\n name: GroupMember\n overlay: true\n children:\n - source.rdb: { table: iam_group_member, role: primary }\n # The composite PK covers (userId, groupId), so \"who is in this group?\" —\n # the other direction — has no index without this one. Same reasoning for\n # every ixSecond below.\n - index.lookup: { name: ixGroup, fields: [groupId] }\n\n - object.entity:\n name: RolePermission\n overlay: true\n children:\n - source.rdb: { table: iam_role_permission, role: primary }\n - index.lookup: { name: ixPermission, fields: [permissionId] }\n\n - object.entity:\n name: UserRole\n overlay: true\n children:\n - source.rdb: { table: iam_user_role, role: primary }\n - index.lookup: { name: ixRole, fields: [roleId] }\n\n - object.entity:\n name: GroupMemberRole\n overlay: true\n children:\n - source.rdb: { table: iam_group_member_role, role: primary }\n # \"who holds this role in this group?\" — the scoped-grant read.\n - index.lookup: { name: ixGroupRole, fields: [groupId, roleId] }\n",
13
- "iam/model": "# library/iam/model.yaml — the CORE layer: identity and access management.\n#\n# Adopters opt in via `libraries: [\"iam\"]` in .metaobjects/config.json.\n#\n# This layer declares NO `source.rdb`, and that is the whole point of the split. A\n# sourceless object is inert by a contract that already ships: migrate skips an object\n# with no writable source, and codegen emits no route, queries, hooks, grid or form for\n# one (both citing #248 — persistability derives from source presence, never from the\n# object subtype). It still gets a type-only interface, so `extends` and reference work.\n#\n# So `libraries: [\"iam\"]` adds ZERO tables and ZERO generated code. What an adopter gains\n# is the design being present and resolvable: an agent working in the repo knows the\n# capability exists and can draw on it, and nothing else happens until the adopter adds\n# `\"iam/db\"`.\n#\n# Authoring discipline (FR-043 §3.1), so the departures are visible:\n# - `field.uuid` + `generation: uuid` on principals; composite ASSIGNED keys on\n# junctions. Never `increment` — a library cannot know the adopter's id strategy.\n# - Physical names carry the `iam_` prefix (in db.yaml): `user` and `group` are\n# reserved words in Postgres, and an adopter has tables of their own.\n# - No adopter-facing profile data. That arrives by `overlay: true`.\n# - No credentials. See requirements.yaml → `noCredentialsOnUser`.\nmetadata:\n package: metaobjects::iam\n children:\n - object.entity:\n name: IamBase\n abstract: true\n description: Shared shape of every iam principal and definition — a stable uuid plus change timestamps. Junctions do not extend it; they are addressed by their participants.\n children:\n - field.uuid: { name: id, required: true }\n - field.timestamp: { name: createdAt, autoSet: onCreate }\n - field.timestamp: { name: updatedAt, autoSet: onUpdate }\n\n - object.entity:\n name: User\n extends: IamBase\n description: A person or service account that can be granted access. Carries no authentication secret of any kind — see the noCredentialsOnUser requirement.\n children:\n - field.string: { name: username, required: true, maxLength: 64, filterable: true }\n - field.string: { name: email, required: true, maxLength: 254, stringFormat: email, filterable: true }\n - field.string: { name: displayName, maxLength: 120 }\n # NOT `filterable: true`, deliberately. The loader warns when a filterable\n # field is in no identity — filtering on it sequential-scans — and a library\n # must not ship a warning to every adopter. `username` and `email` carry it\n # because they have identity.secondary; `status` does not. An adopter who\n # wants to filter on status overlays `filterable` AND an index together,\n # which is exactly what the layer split is for.\n - field.enum: { name: status, required: true, values: [invited, active, suspended, closed], default: active }\n - field.timestamp: { name: emailVerifiedAt }\n - field.timestamp: { name: lastSeenAt }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqUsername, fields: [username] }\n - identity.secondary: { name: uqEmail, fields: [email] }\n - relationship.association: { name: groups, objectRef: Group, cardinality: many, through: GroupMember }\n - relationship.association: { name: roles, objectRef: Role, cardinality: many, through: UserRole }\n\n - object.entity:\n name: GroupType\n extends: IamBase\n description: What KIND of group this is — a team, a tenant, a project. An entity rather than an enum, because \"which roles may be held in this kind of group\" is data an adopter extends, and an enum's values cannot be extended by overlay.\n children:\n - field.string: { name: key, required: true, maxLength: 64 }\n - field.string: { name: name, required: true, maxLength: 120 }\n - field.string: { name: description, maxLength: 500 }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqKey, fields: [key] }\n\n - object.entity:\n name: Group\n extends: IamBase\n description: A nestable collection of users, of a declared GroupType. Nesting is by parentId; acyclicity is an invariant the schema cannot express — see the acyclicGroupNesting requirement.\n children:\n - field.uuid: { name: groupTypeId, required: true }\n - field.uuid: { name: parentId }\n - field.string: { name: key, required: true, maxLength: 64 }\n # Not filterable for the same reason as User.status above.\n - field.string: { name: name, required: true, maxLength: 120 }\n - field.string: { name: description, maxLength: 500 }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqKey, fields: [key] }\n - identity.reference: { name: fkGroupType, fields: [groupTypeId], references: GroupType, onDelete: restrict }\n - identity.reference: { name: fkParent, fields: [parentId], references: Group, onDelete: restrict }\n\n - object.entity:\n name: Role\n extends: IamBase\n description: A reusable bundle of permissions. Code never compares a role NAME to a literal — it asks whether a user holds a permission, and the mapping is data.\n children:\n - field.string: { name: key, required: true, maxLength: 64 }\n - field.string: { name: name, required: true, maxLength: 120 }\n - field.string: { name: description, maxLength: 500 }\n - field.uuid: { name: groupTypeId, description: \"When set, this role may be held only within groups of this type; absent means grantable anywhere.\" }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqKey, fields: [key] }\n - identity.reference: { name: fkGroupType, fields: [groupTypeId], references: GroupType, onDelete: restrict }\n - relationship.association: { name: permissions, objectRef: Permission, cardinality: many, through: RolePermission }\n\n - object.entity:\n name: Permission\n extends: IamBase\n description: \"The assignable unit — a stable <resource>:<action> key the application checks against. An entity, not an enum, on ADR-0037's own reasoning: it has its own identity, its own lifecycle, and a junction with real foreign keys.\"\n children:\n - field.string: { name: key, required: true, maxLength: 128, description: \"Stable <resource>:<action> key the application checks against.\" }\n - field.string: { name: description, maxLength: 500 }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqKey, fields: [key] }\n\n # ---- grant surface: every grant is a row, addressed by its participants ----\n #\n # Junctions do NOT extend IamBase: they have no identity of their own, and adding a\n # surrogate uuid to a row whose identity IS its participants invites a duplicate.\n\n - object.entity:\n name: GroupMember\n description: A user's membership of a group.\n children:\n - field.uuid: { name: userId, required: true }\n - field.uuid: { name: groupId, required: true }\n - field.timestamp: { name: joinedAt, autoSet: onCreate }\n - identity.primary: { name: pk, fields: [userId, groupId], generation: assigned }\n - identity.reference: { name: fkUser, fields: [userId], references: User, onDelete: cascade }\n - identity.reference: { name: fkGroup, fields: [groupId], references: Group, onDelete: cascade }\n\n - object.entity:\n name: RolePermission\n description: A permission granted by a role.\n children:\n - field.uuid: { name: roleId, required: true }\n - field.uuid: { name: permissionId, required: true }\n - identity.primary: { name: pk, fields: [roleId, permissionId], generation: assigned }\n - identity.reference: { name: fkRole, fields: [roleId], references: Role, onDelete: cascade }\n - identity.reference: { name: fkPermission, fields: [permissionId], references: Permission, onDelete: restrict }\n\n - object.entity:\n name: UserRole\n description: A system-wide grant of a role to a user.\n children:\n - field.uuid: { name: userId, required: true }\n - field.uuid: { name: roleId, required: true }\n - field.timestamp: { name: grantedAt, autoSet: onCreate }\n - identity.primary: { name: pk, fields: [userId, roleId], generation: assigned }\n - identity.reference: { name: fkUser, fields: [userId], references: User, onDelete: cascade }\n - identity.reference: { name: fkRole, fields: [roleId], references: Role, onDelete: restrict }\n\n - object.entity:\n name: GroupMemberRole\n description: A grant of a role to a user WITHIN one group. Three foreign keys, so it is not an M:N @through junction (which must declare exactly two identity.reference children); it is read by explicit finders.\n children:\n - field.uuid: { name: userId, required: true }\n - field.uuid: { name: groupId, required: true }\n - field.uuid: { name: roleId, required: true }\n - field.timestamp: { name: grantedAt, autoSet: onCreate }\n - identity.primary: { name: pk, fields: [userId, groupId, roleId], generation: assigned }\n - identity.reference: { name: fkUser, fields: [userId], references: User, onDelete: cascade }\n - identity.reference: { name: fkGroup, fields: [groupId], references: Group, onDelete: cascade }\n - identity.reference: { name: fkRole, fields: [roleId], references: Role, onDelete: restrict }\n",
13
+ "iam/model": "# library/iam/model.yaml — the CORE layer: identity and access management.\n#\n# Adopters opt in via `libraries: [\"iam\"]` in .metaobjects/config.json.\n#\n# This layer declares NO `source.rdb`, and that is the whole point of the split. A\n# sourceless object is inert by a contract that already ships: migrate skips an object\n# with no writable source, and codegen emits no route, queries, hooks, grid or form for\n# one (both citing #248 — persistability derives from source presence, never from the\n# object subtype). It still gets a type-only interface, so `extends` and reference work.\n#\n# So `libraries: [\"iam\"]` adds ZERO tables and ZERO generated code. What an adopter gains\n# is the design being present and resolvable: an agent working in the repo knows the\n# capability exists and can draw on it, and nothing else happens until the adopter adds\n# `\"iam/db\"`.\n#\n# Authoring discipline (FR-043 §3.1), so the departures are visible:\n# - `field.uuid` + `generation: uuid` on principals; composite ASSIGNED keys on\n# junctions. Never `increment` — a library cannot know the adopter's id strategy.\n# - Physical names carry the `iam_` prefix (in db.yaml): `user` and `group` are\n# reserved words in Postgres, and an adopter has tables of their own.\n# - No adopter-facing profile data. That arrives by `overlay: true`.\n# - No credentials. See requirements.yaml → `noCredentialsOnUser`.\nmetadata:\n package: metaobjects::iam\n children:\n - object.entity:\n name: IamBase\n abstract: true\n description: Shared shape of every iam principal and definition — a stable uuid plus change timestamps. Junctions do not extend it; they are addressed by their participants.\n children:\n - field.uuid: { name: id, required: true }\n - field.timestamp: { name: createdAt, autoSet: onCreate }\n - field.timestamp: { name: updatedAt, autoSet: onUpdate }\n\n - object.entity:\n name: User\n extends: IamBase\n description: A person or service account that can be granted access. Carries no authentication secret of any kind — see the noCredentialsOnUser requirement.\n children:\n - field.string: { name: username, required: true, maxLength: 64, filterable: true }\n - field.string: { name: email, required: true, maxLength: 254, stringFormat: email, filterable: true }\n - field.string: { name: displayName, maxLength: 120 }\n # NOT `filterable: true`, deliberately. The loader warns when a filterable\n # field is in no identity — filtering on it sequential-scans — and a library\n # must not ship a warning to every adopter. `username` and `email` carry it\n # because they have identity.secondary; `status` does not. An adopter who\n # wants to filter on status overlays `filterable` AND an index together,\n # which is exactly what the layer split is for.\n - field.enum: { name: status, required: true, values: [invited, active, suspended, closed], default: active }\n - field.timestamp: { name: emailVerifiedAt }\n - field.timestamp: { name: lastSeenAt }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqUsername, fields: [username] }\n - identity.secondary: { name: uqEmail, fields: [email] }\n - relationship.association: { name: groups, objectRef: Group, cardinality: many, through: GroupMember }\n - relationship.association: { name: roles, objectRef: Role, cardinality: many, through: UserRole }\n\n - object.entity:\n name: GroupType\n extends: IamBase\n description: What KIND of group this is — a team, a tenant, a project. An entity rather than an enum, because \"which roles may be held in this kind of group\" is data an adopter extends, and an enum's values cannot be extended by overlay.\n children:\n - field.string: { name: key, required: true, maxLength: 64 }\n - field.string: { name: name, required: true, maxLength: 120 }\n - field.string: { name: description, maxLength: 500 }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqGroupTypeKey, fields: [key] }\n\n - object.entity:\n name: Group\n extends: IamBase\n description: A nestable collection of users, of a declared GroupType. Nesting is by parentId; acyclicity is an invariant the schema cannot express — see the acyclicGroupNesting requirement.\n children:\n - field.uuid: { name: groupTypeId, required: true }\n - field.uuid: { name: parentId }\n - field.string: { name: key, required: true, maxLength: 64 }\n # Not filterable for the same reason as User.status above.\n - field.string: { name: name, required: true, maxLength: 120 }\n - field.string: { name: description, maxLength: 500 }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqGroupKey, fields: [key] }\n - identity.reference: { name: fkGroupType, fields: [groupTypeId], references: GroupType, onDelete: restrict }\n - identity.reference: { name: fkParent, fields: [parentId], references: Group, onDelete: restrict }\n\n - object.entity:\n name: Role\n extends: IamBase\n description: A reusable bundle of permissions. Code never compares a role NAME to a literal — it asks whether a user holds a permission, and the mapping is data.\n children:\n - field.string: { name: key, required: true, maxLength: 64 }\n - field.string: { name: name, required: true, maxLength: 120 }\n - field.string: { name: description, maxLength: 500 }\n - field.uuid: { name: groupTypeId, description: \"When set, this role may be held only within groups of this type; absent means grantable anywhere.\" }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqRoleKey, fields: [key] }\n - identity.reference: { name: fkGroupType, fields: [groupTypeId], references: GroupType, onDelete: restrict }\n - relationship.association: { name: permissions, objectRef: Permission, cardinality: many, through: RolePermission }\n\n - object.entity:\n name: Permission\n extends: IamBase\n description: \"The assignable unit — a stable <resource>:<action> key the application checks against. An entity, not an enum, on ADR-0037's own reasoning: it has its own identity, its own lifecycle, and a junction with real foreign keys.\"\n children:\n - field.string: { name: key, required: true, maxLength: 128, description: \"Stable <resource>:<action> key the application checks against.\" }\n - field.string: { name: description, maxLength: 500 }\n - identity.primary: { name: pk, fields: [id], generation: uuid }\n - identity.secondary: { name: uqPermissionKey, fields: [key] }\n\n # ---- grant surface: every grant is a row, addressed by its participants ----\n #\n # Junctions do NOT extend IamBase: they have no identity of their own, and adding a\n # surrogate uuid to a row whose identity IS its participants invites a duplicate.\n\n - object.entity:\n name: GroupMember\n description: A user's membership of a group.\n children:\n - field.uuid: { name: userId, required: true }\n - field.uuid: { name: groupId, required: true }\n - field.timestamp: { name: joinedAt, autoSet: onCreate }\n - identity.primary: { name: pk, fields: [userId, groupId], generation: assigned }\n - identity.reference: { name: fkUser, fields: [userId], references: User, onDelete: cascade }\n - identity.reference: { name: fkGroup, fields: [groupId], references: Group, onDelete: cascade }\n\n - object.entity:\n name: RolePermission\n description: A permission granted by a role.\n children:\n - field.uuid: { name: roleId, required: true }\n - field.uuid: { name: permissionId, required: true }\n - identity.primary: { name: pk, fields: [roleId, permissionId], generation: assigned }\n - identity.reference: { name: fkRole, fields: [roleId], references: Role, onDelete: cascade }\n - identity.reference: { name: fkPermission, fields: [permissionId], references: Permission, onDelete: restrict }\n\n - object.entity:\n name: UserRole\n description: A system-wide grant of a role to a user.\n children:\n - field.uuid: { name: userId, required: true }\n - field.uuid: { name: roleId, required: true }\n - field.timestamp: { name: grantedAt, autoSet: onCreate }\n - identity.primary: { name: pk, fields: [userId, roleId], generation: assigned }\n - identity.reference: { name: fkUser, fields: [userId], references: User, onDelete: cascade }\n - identity.reference: { name: fkRole, fields: [roleId], references: Role, onDelete: restrict }\n\n - object.entity:\n name: GroupMemberRole\n description: A grant of a role to a user WITHIN one group. Three foreign keys, so it is not an M:N @through junction (which must declare exactly two identity.reference children); it is read by explicit finders.\n children:\n - field.uuid: { name: userId, required: true }\n - field.uuid: { name: groupId, required: true }\n - field.uuid: { name: roleId, required: true }\n - field.timestamp: { name: grantedAt, autoSet: onCreate }\n - identity.primary: { name: pk, fields: [userId, groupId, roleId], generation: assigned }\n - identity.reference: { name: fkUser, fields: [userId], references: User, onDelete: cascade }\n - identity.reference: { name: fkGroup, fields: [groupId], references: Group, onDelete: cascade }\n - identity.reference: { name: fkRole, fields: [roleId], references: Role, onDelete: restrict }\n",
14
14
  "iam/requirements": "# library/iam/requirements.yaml — what this library's design PROMISES.\n#\n# This is what makes iam a library rather than a schema snippet. Without requirements an\n# adopter gets nine tables; with them they get nine tables plus a build that is held to\n# \"no authorization decision is hard-wired to a name\", which no snippet can do.\n#\n# Two reading rules, both load-bearing:\n#\n# `live` here means \"the model AS SHIPPED realises this\" — never \"your application\n# does\". A ledger binds to model nodes; runtime guarantees are the runtime's tests, and\n# this library does not invent a way to point a requirement at code (@verifiedBy was\n# retired for exactly that). Behaviour the model cannot carry ships as `partial` +\n# `disposition: accepted` with a notes sentence naming what the adopter must do.\n#\n# The functional tree roots at L2, not L1. L1 is the adopter's SOLUTION, and a library\n# is by definition a segment of someone else's. Architectural claims ship flat.\n#\n# HIERARCHY IS NESTING, and the L4/L5 split is grain. The concerns are CHILDREN of the L2\n# rather than its siblings, and an L4 names the OBJECT it is about while the field that\n# carries it hangs off it as an L5 child. Written flat, the L2 claims nothing in its whole\n# subtree; written at L4, a field reference is ERR_REQUIREMENT_L4_NOT_OBJECT. Both shipped\n# here until the standalone verify gate existed (`cli/test/shipped-library-verify.test.ts`).\nmetadata:\n package: metaobjects::iam\n children:\n # ---- functional: the L2 segment and the concerns nested under it --------\n - requirement.functional:\n name: accessControl\n level: 2\n status: live\n statement: Who may do what is answered from stored grants, never from a name compared to a literal in code.\n counterexample: A branch that reads `if (user.role === \"admin\")`.\n description: The segment this library covers. The concerns beneath it are what it decomposes into.\n children:\n - requirement.functional:\n name: identity\n level: 4\n status: live\n statement: A person or service account is represented once, addressed by a uuid, and reachable by username or email.\n counterexample: Two rows for the same person because the email changed.\n implementedBy: [User]\n\n - requirement.functional:\n name: grouping\n level: 4\n status: live\n statement: Users are collected into typed, nestable groups, and the kind of group is data rather than a hard-coded set.\n counterexample: A `teamOrTenant` boolean.\n implementedBy: [Group, GroupType, GroupMember]\n\n - requirement.functional:\n name: acyclicGroupNesting\n level: 4\n status: partial\n disposition: accepted\n statement: A group is never its own ancestor.\n counterexample: Two groups each naming the other as parent.\n notes: >-\n The schema cannot express this — a self-referencing FK admits a cycle, and the\n only relational forms that would catch it (a recursive CHECK, a closure table\n maintained by trigger) are DB-specific and would not survive three dialects.\n The adopter enforces it where the write happens. Recorded rather than omitted\n so an agent reading the ledger before adding a parent-setting endpoint sees the\n obligation.\n implementedBy: [Group]\n\n - requirement.functional:\n name: grants\n level: 4\n status: live\n statement: A role is granted to a user either system-wide or scoped to one group, and both are ordinary rows.\n counterexample: A nullable `groupId` on one grant table, where NULL means \"everywhere\".\n description: >-\n Two junctions, not one with a nullable scope. A NULL in a unique key is DISTINCT\n from every other NULL in SQL, so a nullable-scope design lets the same global\n grant be inserted twice; the fix needs a partial index whose expression carries\n a physical column name. Two composite-keyed tables need no escape hatch and\n survive three dialects and five ports unchanged.\n implementedBy: [UserRole, GroupMemberRole]\n\n - requirement.functional:\n name: roleScopedToGroupType\n level: 4\n status: partial\n disposition: accepted\n statement: A role bound to a group type is granted only within groups of that type.\n counterexample: A \"tenant admin\" role granted inside a project group.\n notes: >-\n Expressing this relationally needs the grant row to carry the group's type and\n a composite FK back to (group, type) — three foreign keys deep, unverified\n across five ports' DDL and ORM paths. The adopter checks it at the point of\n grant. The declared half is the L5 child below; the enforcement is not.\n implementedBy: [Role, GroupMemberRole]\n children:\n - requirement.functional:\n name: roleDeclaresItsGroupType\n level: 5\n status: live\n statement: A role declares the group type it is bound to, as a nullable reference.\n counterexample: A role whose intended scope is recoverable only from its name.\n description: >-\n `live` where its parent is `partial`, and the split is grain as much as\n verdict: the DECLARATION is shipped and resolves against the field itself,\n so dropping the column fails the build — while the ENFORCEMENT, which no\n schema here can carry, stays the parent's accepted gap.\n implementedBy: [Role.groupTypeId]\n\n - requirement.functional:\n name: decision\n level: 4\n status: live\n statement: An authorization decision is the question \"does this user hold this permission key\", answered from rows.\n counterexample: A hard-coded list of usernames that bypass a check.\n implementedBy: [Permission, RolePermission]\n\n # ---- architectural: prohibitions in force --------------------------------\n\n - requirement.architectural:\n name: grantsAreRows\n status: live\n statement: A grant exists only as a stored row; nothing is granted by naming, position or convention.\n counterexample: A superuser recognised by username.\n implementedBy: [UserRole, GroupMemberRole, RolePermission, GroupMember]\n\n - requirement.architectural:\n name: noCredentialsOnUser\n status: live\n statement: A user row carries no authentication secret — no password, no hash, no knowledge-based question or answer.\n counterexample: A password or secret-answer column on the user table.\n description: >-\n Authentication is a separate capability with an entity per factor; this library\n is identity and authorization only.\n notes: >-\n This is the one thing every reader of a user table proposes adding, and a real\n legacy model of this shape stored a length-bounded plaintext password and a\n knowledge-based secret pair on the user row. Stating it as a prohibition IN\n FORCE — claimable, and rendered on agent/requirements.md — is what stops an\n agent extending \"the user model\" from re-deriving it on sight. It is\n `architectural`, not `retired`: retired is chartered for a capability built\n here and removed, and this library never built one.\n implementedBy: [User]\n\n - requirement.architectural:\n name: principalDeletionRevokesGrants\n status: live\n statement: Deleting a user or group removes its grants; deleting a role or permission still in use is refused.\n counterexample: A grant row pointing at a user who no longer exists.\n description: The referential rule in one sentence — cascade from a principal, restrict from a definition.\n implementedBy: [GroupMember, UserRole, GroupMemberRole, RolePermission]\n\n - requirement.architectural:\n name: stableIdentifiers\n status: live\n statement: Every principal and definition is addressed by a uuid that never changes; every grant by its participants.\n counterexample: A group referenced by its display name.\n implementedBy: [IamBase]\n",
15
15
  };
16
16
 
@@ -25,7 +25,7 @@ import { ParseError } from "../errors.js";
25
25
  import type { LoaderWarning } from "../source.js";
26
26
  import { codeSource, resolvedSource } from "../source.js";
27
27
  import { parseJson } from "../parser-json.js";
28
- import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateOneSideReferenceResolution, validateIndexLookupFields, validateProjectionFilter, validateRetiredRequirementLinks } from "./validation-passes.js";
28
+ import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateOneSideReferenceResolution, validateM2MJunctionPairing, validateIndexLookupFields, validateProjectionFilter, validateRetiredRequirementLinks } from "./validation-passes.js";
29
29
  import { runRegisteredValidation } from "./validation-registry.js";
30
30
  import { validateSourceRoles } from "../persistence/source/validate-source-roles.js";
31
31
  import { validateSourceEscapes } from "../persistence/source/validate-source-escapes.js";
@@ -658,6 +658,14 @@ export class MetaDataLoader {
658
658
  // naming the candidates (ADR-0029 §5).
659
659
  errors.push(...validateOneSideReferenceResolution(root));
660
660
 
661
+ // Rule (f) — a M:N junction must PAIR: one identity.reference to the
662
+ // navigating entity, one to the @objectRef target. Rule (d) checks only
663
+ // that there are TWO references, never what they point at, so an
664
+ // unpairable junction loaded clean and then failed differently in every
665
+ // port's codegen — a silent missing route in TS/C#, a failed build in
666
+ // Java/Kotlin/Python.
667
+ errors.push(...validateM2MJunctionPairing(root));
668
+
661
669
  // index.lookup @fields resolution — each index.lookup must name ≥1 field,
662
670
  // and every field must exist in the entity's effective (resolved) field set
663
671
  // (ADR-0039: resolving accessor, so inherited fields via extends are visible).
@@ -134,6 +134,10 @@ import {
134
134
  resolveRelationshipReference,
135
135
  } from "../core/relationship/resolve-relationship-reference.js";
136
136
  import type { MetaRoot } from "../shared/meta-root.js";
137
+ import type { MetaRelationship } from "../core/relationship/meta-relationship.js";
138
+ // Rule (f) runs the REAL M:N derivation so the loader and codegen cannot drift.
139
+ import { deriveM2MFields, M2MDerivationError } from "../core/relationship/derive-m2m-fields.js";
140
+ import { isReadOnlySource, isWritableSource } from "../shared/node-guards.js";
137
141
  import { stripPackage } from "../naming.js";
138
142
  import {
139
143
  FILTER_COMPOSE_OR,
@@ -2250,6 +2254,115 @@ export function validateOneSideReferenceResolution(root: MetaRoot): ParseError[]
2250
2254
  return errors;
2251
2255
  }
2252
2256
 
2257
+ // ---------------------------------------------------------------------------
2258
+ // Rule (f) — a M:N junction must PAIR: one identity.reference resolving to the
2259
+ // navigating entity, one to the @objectRef target.
2260
+ //
2261
+ // Rule (d) already checks that the junction declares exactly TWO references. It
2262
+ // never checked WHAT they point at, so a junction whose two references name some
2263
+ // other pair of entities loaded perfectly clean and then failed — differently —
2264
+ // in every port's codegen: TypeScript and C# warned and emitted no traversal
2265
+ // route (the endpoint simply 404s, with nothing in the build output a CI gate
2266
+ // would fail on), while Java, Kotlin and Python threw and failed the build. One
2267
+ // input, five contracts, and the quiet arm is the dangerous one.
2268
+ //
2269
+ // The check belongs HERE because the model is what is wrong, and because a
2270
+ // loader error is the only answer every port already agrees on. Both codegen
2271
+ // branches become unreachable for a model that loaded.
2272
+ //
2273
+ // It runs the REAL derivation (deriveM2MFields) rather than reimplementing the
2274
+ // pairing, so the loader and the FK derivation cannot drift apart — the same
2275
+ // reason _junctionReferences above shares referenceIdentities() with codegen. A
2276
+ // parallel check would be a second opinion, and the defect class being closed
2277
+ // here is precisely "two parts of the pipeline disagree".
2278
+ //
2279
+ // SCOPE mirrors codegen's own iteration exactly (relation-resolver.ts): every
2280
+ // CONCRETE, non-projection object crossed with its EFFECTIVE relationships.
2281
+ // That is not incidental —
2282
+ // * per (entity, relationship), NOT deduped by declaration like rule (d):
2283
+ // pairing is a property of the NAVIGATING entity. deriveM2MFields accepts a
2284
+ // junction reference naming either the declaring entity or the navigating
2285
+ // one, so an inherited M:N can pair from one subtype and not another, and
2286
+ // checking the declaration alone would both miss that and reject models
2287
+ // codegen accepts.
2288
+ // * abstract levels are skipped because their own declarations do not file —
2289
+ // an abstract base's FK reaches a table only through a concrete descendant,
2290
+ // whose own walk reaches this same relationship.
2291
+ // * projections are skipped because they never emit a relations() block.
2292
+ // Widening past that set would fail builds codegen never had a problem with.
2293
+ // ---------------------------------------------------------------------------
2294
+
2295
+ /** codegen-ts's isProjection, reproduced here: that module is not reachable from
2296
+ * the loader, and the two source guards it is built from live in this package.
2297
+ * ADR-0039: own — projection-ness is the entity's OWN declared source @kind. */
2298
+ function isProjectionObject(obj: MetaObject): boolean {
2299
+ const own = obj.ownChildren();
2300
+ return own.some(isReadOnlySource) && !own.some(isWritableSource);
2301
+ }
2302
+
2303
+ export function validateM2MJunctionPairing(root: MetaRoot): ParseError[] {
2304
+ const errors: ParseError[] = [];
2305
+ for (const obj of root.objects()) {
2306
+ if (obj.isAbstract === true) continue;
2307
+ if (isProjectionObject(obj)) continue;
2308
+ // ADR-0039: resolving — an M:N relationship may be reached only via extends.
2309
+ for (const rel of obj.relationships()) {
2310
+ if (rel.attr(RELATIONSHIP_ATTR_CARDINALITY) !== CARDINALITY_MANY) continue;
2311
+ if (rel.attr(RELATIONSHIP_ATTR_THROUGH) === undefined) continue;
2312
+ try {
2313
+ deriveM2MFields(rel, obj, root);
2314
+ } catch (err) {
2315
+ if (!(err instanceof M2MDerivationError)) throw err;
2316
+ // Rule (d) reports the malformed-junction cases it owns (missing
2317
+ // @through, unresolvable @through, a reference count other than two, an
2318
+ // @sourceRefField matching no FK) against the DECLARING entity, and the
2319
+ // derivation re-checks them. Reporting them again here — once per
2320
+ // concrete subtype — would bury the pairing finding under duplicates of
2321
+ // a diagnosis the author already has, so those are left to rule (d) and
2322
+ // this rule speaks only where it is the sole witness.
2323
+ if (isJunctionShapeErrorOwnedByRuleD(rel, root)) continue;
2324
+ errors.push(
2325
+ new ParseError(
2326
+ `relationship "${obj.name}.${rel.name}" cannot be resolved through its ` +
2327
+ `@${RELATIONSHIP_ATTR_THROUGH} junction: ${err.message}. Without a reference to ` +
2328
+ `"${obj.name}" the junction rows cannot be filtered to this entity, so no ` +
2329
+ `traversal is derivable.`,
2330
+ { code: "ERR_INVALID_RELATIONSHIP", source: rel.source },
2331
+ ),
2332
+ );
2333
+ }
2334
+ }
2335
+ }
2336
+ return errors;
2337
+ }
2338
+
2339
+ /**
2340
+ * True when the derivation's failure is one rule (d) already reports — a
2341
+ * junction that is missing, unresolvable, or does not declare exactly two
2342
+ * identity.reference children. Keyed on the SHAPE rather than on the message
2343
+ * text: matching strings would make this silently stop de-duplicating the first
2344
+ * time a message is reworded, and a gate keyed to prose breaks on the rewrite.
2345
+ */
2346
+ function isJunctionShapeErrorOwnedByRuleD(rel: MetaRelationship, root: MetaRoot): boolean {
2347
+ const through = rel.attr(RELATIONSHIP_ATTR_THROUGH);
2348
+ if (typeof through !== "string" || through === "") return true;
2349
+ // Resolve exactly as rule (d) and deriveM2MFields do — ADR-0042 via the single
2350
+ // resolveObjectRef matcher, referred from the DECLARING entity's package. A
2351
+ // second resolution rule here is what made this helper disagree with the
2352
+ // derivation and emit rule (d)'s finding a second time.
2353
+ const declaring = rel.parent;
2354
+ const referrerPkg = declaring?.package ?? declaring?.fileDefaultPackage ?? "";
2355
+ const junction = _findObject(root, through, referrerPkg) as MetaObject | undefined;
2356
+ if (junction === undefined) return true;
2357
+ if (junction.subType !== OBJECT_SUBTYPE_ENTITY) return true;
2358
+ if (_countJunctionReferences(junction) !== 2) return true;
2359
+ const sourceRefField = rel.attr(RELATIONSHIP_ATTR_SOURCE_REF_FIELD);
2360
+ if (typeof sourceRefField === "string" && sourceRefField !== "") {
2361
+ if (!_junctionReferenceFkFields(junction).includes(sourceRefField)) return true;
2362
+ }
2363
+ return false;
2364
+ }
2365
+
2253
2366
  /**
2254
2367
  * Render a candidate reference as `name(fkField)`, or `name(fieldA, fieldB)`
2255
2368
  * for a composite reference — so two composite references sharing a first
@@ -302,6 +302,24 @@ function isAbstractAnchorFor(type: string, registry: TypeRegistry): boolean {
302
302
  return registry.allSubTypesOf(type).some((sub) => sub !== SUBTYPE_BASE);
303
303
  }
304
304
 
305
+ /**
306
+ * How a (type, subType) pair is named back to the author in an error.
307
+ *
308
+ * A bare wrapper key for an UNREGISTERED type has no subType to resolve, so the naive
309
+ * `${type}.${subType}` renders `"madeup."` — a trailing dot that reads like the author
310
+ * wrote one. Print the type alone in that case; it is what they actually wrote.
311
+ */
312
+ function typeDisplayName(type: string, subType: string): string {
313
+ return subType === "" ? type : `${type}${TYPE_SUBTYPE_SEPARATOR}${subType}`;
314
+ }
315
+
316
+ /** The JSON kind of a value, for an error that has to say what the author wrote instead. */
317
+ function describeJsonKind(value: unknown): string {
318
+ if (value === null) return "null";
319
+ if (Array.isArray(value)) return "an array";
320
+ return `a ${typeof value}`;
321
+ }
322
+
305
323
  function abstractSubtypeMessage(type: string): string {
306
324
  return (
307
325
  `"${type}.${SUBTYPE_BASE}" may not be authored — every "${SUBTYPE_BASE}" subtype is an ` +
@@ -596,19 +614,6 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
596
614
  const rootKey = wrapperKeys[0]!;
597
615
  const rootData = topLevel[rootKey];
598
616
 
599
- if (typeof rootData !== "object" || rootData === null || Array.isArray(rootData)) {
600
- // The error context references the rootKey wrapper; push it so the
601
- // envelope's jsonPath includes it (matches the legacy `path` slot).
602
- _currentPath!.pushKey(rootKey);
603
- const src = errSource();
604
- _currentPath!.pop();
605
- throw new ParseError(
606
- `Top-level wrapper "${rootKey}" must contain an object`,
607
- { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: src },
608
- );
609
- }
610
-
611
- const rootDataObj = rootData as Record<string, unknown>;
612
617
  const { type: rootType, subType: rootSubType, explicit: rootExplicit } =
613
618
  splitTypeKey(rootKey, opts.registry);
614
619
 
@@ -649,7 +654,7 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
649
654
  // other" is how a rule ends up half-true and passing every probe.
650
655
  const retiredRoot = retiredSubType(rootType, rootSubType);
651
656
  throw new ParseError(
652
- `Unknown root type "${rootType}.${rootSubType}" — ` +
657
+ `Unknown root type "${typeDisplayName(rootType, rootSubType)}" — ` +
653
658
  (retiredRoot !== undefined ? retirementHint(retiredRoot) : "not registered"),
654
659
  {
655
660
  code: rootTypeCode,
@@ -661,6 +666,26 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
661
666
  );
662
667
  }
663
668
 
669
+ // --- The root wrapper's BODY must be an object ---
670
+ // AFTER the key resolves — the same order as the CHILD door (see processChildren).
671
+ // One rule, both doors, in the same order at both: until 1.0.5 this door ran the body
672
+ // check first, the child door ran it first too but under a DIFFERENT code, and the JVM
673
+ // root door ran it last with no error code at all.
674
+ if (typeof rootData !== "object" || rootData === null || Array.isArray(rootData)) {
675
+ // The error context references the rootKey wrapper; push it so the
676
+ // envelope's jsonPath includes it (matches the legacy `path` slot).
677
+ _currentPath!.pushKey(rootKey);
678
+ const src = errSource();
679
+ _currentPath!.pop();
680
+ throw new ParseError(
681
+ `Top-level wrapper "${rootKey}" must contain an object, and holds ` +
682
+ `${describeJsonKind(rootData)}`,
683
+ { code: "ERR_TOP_LEVEL_NOT_OBJECT", source: src },
684
+ );
685
+ }
686
+
687
+ const rootDataObj = rootData as Record<string, unknown>;
688
+
664
689
  // FR5a — push the wrapper-key segment onto the JSONPath stack so all
665
690
  // descendants emit jsonPath strings rooted at "$.<rootKey>". The merge-mode
666
691
  // path keeps the existing root's source untouched; only NEW children get
@@ -762,7 +787,7 @@ function parseNodeFresh(
762
787
  if (registry.has(type, SUBTYPE_BASE)) {
763
788
  subType = SUBTYPE_BASE;
764
789
  } else {
765
- const msg = `Unknown type "${type}.${subType}" — not registered`;
790
+ const msg = `Unknown type "${typeDisplayName(type, subType)}" — not registered`;
766
791
  errors.push(new ParseError(msg, { code: "ERR_UNKNOWN_TYPE", source: errSource() }));
767
792
  const rawName = nodeData[RESERVED_KEY_NAME];
768
793
  const name = typeof rawName === "string" ? rawName : "";
@@ -1666,18 +1691,6 @@ function processChildren(
1666
1691
  _currentYamlPosition = getYamlPosition(childRecord, childKey);
1667
1692
  }
1668
1693
 
1669
- if (typeof childData !== "object" || childData === null || Array.isArray(childData)) {
1670
- reportProblem(
1671
- `Child wrapper "${childKey}" at ${childNodePath} must contain an object`,
1672
- strict, warnings, "ERR_TOP_LEVEL_NOT_OBJECT",
1673
- );
1674
- _currentPath?.pop(); // pop child wrapper key
1675
- _currentPath?.pop(); // pop array index
1676
- _currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
1677
- continue;
1678
- }
1679
-
1680
- const childDataObj = childData as Record<string, unknown>;
1681
1694
  const { type: childType, subType: childSubTypeRaw, explicit } = splitTypeKey(childKey, registry);
1682
1695
 
1683
1696
  let childSubType = childSubTypeRaw;
@@ -1687,8 +1700,17 @@ function processChildren(
1687
1700
  // (identity, index, requirement) falls into that check's else-arm first and is reported as
1688
1701
  // an UNKNOWN TYPE — a message asserting the type does not exist, about a type that does,
1689
1702
  // with its name mangled to `"identity."`. Order is the whole rule here.
1690
- if ((childSubType === SUBTYPE_BASE && isAbstractAnchorFor(childType, registry))
1691
- || (!explicit && childSubType === "")) {
1703
+ //
1704
+ // Registration first, exactly as the ROOT door does it: an UNREGISTERED type has no
1705
+ // declared default either, so without this guard a typo'd child key (`madeup`) lands in
1706
+ // this branch and is told to "write the full `madeup.<subType>`" — advice about a type
1707
+ // that does not exist. The root door has carried the guard since FR5a and Java pins it
1708
+ // with a root-door test; the child door never got it, so one rule was true at one of its
1709
+ // two doors in TypeScript, C# and Java, while Python reported ERR_UNKNOWN_TYPE. The
1710
+ // registration check below owns the unregistered case and phrases it correctly.
1711
+ if (registry.allSubTypesOf(childType).length > 0
1712
+ && ((childSubType === SUBTYPE_BASE && isAbstractAnchorFor(childType, registry))
1713
+ || (!explicit && childSubType === ""))) {
1692
1714
  errors.push(
1693
1715
  explicit
1694
1716
  ? new ParseError(abstractSubtypeMessage(childType), {
@@ -1723,7 +1745,7 @@ function processChildren(
1723
1745
  const retiredChild = retiredSubType(childType, childSubType);
1724
1746
  errors.push(
1725
1747
  new ParseError(
1726
- `Unknown type "${childType}.${childSubType}" — ` +
1748
+ `Unknown type "${typeDisplayName(childType, childSubType)}" — ` +
1727
1749
  (retiredChild !== undefined ? retirementHint(retiredChild) : "not registered"),
1728
1750
  { code: childTypeCode, source: errSource() },
1729
1751
  ),
@@ -1735,6 +1757,35 @@ function processChildren(
1735
1757
  }
1736
1758
  }
1737
1759
 
1760
+ // --- The wrapper's BODY must be an object ---
1761
+ // AFTER the key resolves, deliberately. A child is one `{ "<type>.<subType>": { …body… } }`
1762
+ // pair, so a string/number/boolean/array/null body carries no node to read — but running
1763
+ // this check FIRST answers `{ "$comment": "prose" }` with "must contain an object", which
1764
+ // sends the author to wrap prose that was never a node in the first place. Key first, body
1765
+ // second: each mistake gets the message about itself.
1766
+ //
1767
+ // A hard ERROR, not `reportProblem`'s lax warning (1.0.5). Under a warning the child was
1768
+ // DROPPED from the tree on every port and the load still succeeded — a declared field
1769
+ // simply not there, with `meta gen` emitting a table without its column. Four ports
1770
+ // disagreed about it besides: TypeScript and C# warned, Java logged to slf4j and put
1771
+ // nothing in the envelope at all, and Python dropped a registered type's bad body in
1772
+ // silence. See docs/compatibility-policy.md, "Correcting input we wrongly accepted".
1773
+ if (typeof childData !== "object" || childData === null || Array.isArray(childData)) {
1774
+ errors.push(
1775
+ new ParseError(
1776
+ `Child wrapper "${childKey}" at ${childNodePath} must contain an object — ` +
1777
+ `a child is one { "<type>.<subType>": { …node body… } } pair, and this one holds ` +
1778
+ `${describeJsonKind(childData)}. Give it a node body, or remove the entry.`,
1779
+ { code: "ERR_CHILD_NOT_OBJECT", source: errSource() },
1780
+ ),
1781
+ );
1782
+ _currentPath?.pop(); // pop child wrapper key
1783
+ _currentPath?.pop(); // pop array index
1784
+ _currentYamlPosition = savedYamlPosition; // FR5b — restore parent's pos
1785
+ continue; // skip this child
1786
+ }
1787
+ const childDataObj = childData as Record<string, unknown>;
1788
+
1738
1789
  // A `<type>.base` child may not be AUTHORED — the CHILD door (the root door is above).
1739
1790
  // Gated on `explicit`, deliberately: `base` is also this parser's fallback for an
1740
1791
  // OMITTED subtype whose registry default is unregistered (the branch directly above),