Canonical JSON is the on-disk interchange format — what conformance fixtures pin, what loaders cross-port serialize byte-identical, what tooling that isn't a MetaObjects port can still parse. YAML is the universal authoring front-end — sigil-free, AI-friendly, and lowered to canonical JSON at load time so it shares the entire downstream pipeline.
The full rationale is in ADR-0006.
YAML is desugared to canonical JSON inside the loader. The rules are pinned
cross-port in fixtures/yaml-conformance/.
Bare YAML keys are interpreted as attribute names. The desugar re-adds the @
prefix when lowering. Structural keywords (name, package, extends,
abstract, overlay, isArray, children, value) stay bare.
YAML:
- field.string:
name: sku
column: sku_code
required: true
maxLength: 200Canonical JSON:
{ "field.string": {
"name": "sku",
"@column": "sku_code",
"@required": true,
"@maxLength": 200
}}A field-subtype key with a trailing [] declares an array-valued field. The
desugar splits the suffix off and emits "isArray": true.
YAML:
- field.long[]: weekIdsCanonical JSON:
{ "field.long": { "name": "weekIds", "isArray": true } }If a key uses the bare type name without a subtype (e.g. field: instead of
field.string:), the desugar applies the default subtype for the type (string
for field, entity for object, etc.).
YAML 1.2 implicit type-coercion (a bare yes/no/on/off parsing as boolean,
a bare 2026-05-25 parsing as a date) is the single biggest source of subtle
authoring bugs. The loader rejects coerced values in attribute positions that
declare a different dataType. The fixtures
fixtures/yaml-conformance/error-yaml-coerced-bool-in-string and
error-yaml-coerced-num-in-enum pin the behavior.
A coerced bool in a string slot:
- field.string:
name: status
default: yes # YAML parses this as `true`, not the string "yes"…fails the load with a coercion-guard error. Fix by quoting:
- field.string:
name: status
default: "yes"The same Author entity in YAML and canonical JSON.
metadata:
package: acme::blog
children:
- object.entity:
name: Author
children:
- source.rdb: { table: authors }
- field.long: { name: id }
- field.string: { name: name, required: true, maxLength: 200 }
- field.string: { name: bio, maxLength: 2000 }
- identity.primary: { fields: id, generation: increment }{
"metadata.root": {
"package": "acme::blog",
"children": [
{
"object.entity": {
"name": "Author",
"children": [
{ "source.rdb": { "@table": "authors" } },
{ "field.long": { "name": "id" } },
{ "field.string": { "name": "name", "@required": true, "@maxLength": 200 } },
{ "field.string": { "name": "bio", "@maxLength": 2000 } },
{ "identity.primary": { "@fields": "id", "@generation": "increment" } }
]
}
}
]
}
}A single directory may freely mix *.json and *.yaml (or *.yml) files. The
loader picks both up, applies the same deterministic ordinal-filename sort, and
runs the same pipeline downstream. Overlay merge (a YAML file extending or
overriding a JSON-declared entity in the same package) works identically.
@-prefixing a reserved structural keyword is invalid because it would collide
with the structural slot of the same name:
- field.long:
name: weekIds
"@isArray": true # ERR_RESERVED_ATTR — use the bare key `isArray` or the `[]` suffixThe right form is isArray: true (bare) or field.long[]: (suffix).
The YAML desugar is a loader-only concern — no port-specific generated code differs because of YAML vs JSON authoring. The downstream codegen sees the canonical JSON tree and emits identical output regardless of which authoring format the metadata was on disk in.
| Port | YAML loader support |
|---|---|
| TypeScript | Yes — @metaobjectsdev/metadata desugars at load |
| Java | Yes — metaobjects-metadata desugars at load |
| Kotlin | Yes — via the Java loader |
| C# | Yes — MetaObjects.Loader desugars at load (via YamlDotNet) |
| Python | Yes — metaobjects.loader desugars at load (via PyYAML) |
The following conformance fixtures gate this feature's behavior across ports:
fixtures/yaml-conformance/yaml-sigil-free-attrs/— bare attrs in YAML re-acquire@on loweringfixtures/yaml-conformance/yaml-array-suffix/—field.long[]: namearray-suffix sugarfixtures/yaml-conformance/yaml-mixed-bare-and-prefixed/— bare +@-prefixed attrs coexistfixtures/yaml-conformance/error-yaml-reserved-as-attr/— reserved structural keyword cannot be used as a bare attrfixtures/yaml-conformance/error-yaml-coerced-bool-in-string/— YAML 1.1 bool-coercion guard for string fieldsfixtures/yaml-conformance/error-yaml-coerced-num-in-enum/— YAML 1.1 number-coercion guard for enum members
Plus the error-parse-malformed-json
fixture in the metamodel corpus, which the YAML loader piggy-backs on through the
JSON tail-stage of the pipeline.
Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these
via their respective conformance runners. See docs/CONFORMANCE.md
for the per-port pass/skip ledger.
- loaders.md — the pipeline that consumes the desugared JSON
- entities.md — the
object.entityform in both formats - field-types.md — the field subtypes you'll author most
- ADR-0006 — design rationale
fixtures/yaml-conformance/— every desugar rule pinned cross-port