|
1 | | -# ADR-0006 — Metadata document structure: reserved structural keywords vs `@`-prefixed inline attributes |
| 1 | +# ADR-0006 — Metadata keys are bare: a closed reserved-keyword set + open inline attributes (no sigil) |
2 | 2 |
|
3 | 3 | **Status:** Proposed (2026-05-23) |
4 | 4 |
|
5 | 5 | ## Context |
6 | 6 |
|
7 | | -Every metadata node serializes as a single fused-key object — `{"object.entity": { ...body... }}` — whose **body** mixes two kinds of keys: |
| 7 | +A metadata node body mixes two kinds of keys: a small **closed** set of reserved structural |
| 8 | +keywords (`name`, `package`, `extends`, `abstract`, `overlay`, `isArray`, `children`, `value`) |
| 9 | +and an **open** set of inline attributes (`schema`, `objectRef`, `maxLength`, `payloadRef`, …). |
| 10 | +The original design distinguished them with an `@` sigil on attributes (`@schema`), reserved |
| 11 | +words bare. |
8 | 12 |
|
9 | | -- **Reserved structural keywords** — a small, *closed* vocabulary that describes the node's |
10 | | - shape and identity: `name`, `package`, `extends`, `abstract`, `overlay`, `isArray`, |
11 | | - `children`, `value`. Written **bare** (no sigil). |
12 | | -- **Inline attributes** — an *open*, extensible namespace of typed `@`-prefixed key/values |
13 | | - (`@payloadRef`, `@dbColumn`, `@maxLength`, …) that decorate a node. |
| 13 | +Two problems killed the sigil: |
14 | 14 |
|
15 | | -`spec/wire-format.md` already states this ("the reserved structural keys are exactly those |
16 | | -listed … everything else is either an `@`-prefixed attribute or invalid", and "`isArray` … |
17 | | -structural, NOT an `@`-attr"). The two namespaces are meant to be **disjoint**: the `@` sigil |
18 | | -is *exclusively* the inline-attribute namespace; reserved words are *exclusively* bare. |
| 15 | +1. **`@` is hostile to YAML.** `@` is a YAML reserved indicator, so `@schema:` can't be a plain |
| 16 | + key — every attribute would need quoting (`"@schema":`). Across the attribute-heavy source-v2 |
| 17 | + and persistence designs that's relentless. (The incident that surfaced this: `@isArray`, a |
| 18 | + reserved word wrongly given the attr sigil.) |
| 19 | +2. **The sigil is redundant.** The reserved keyword set is **closed and known to the loader**, so |
| 20 | + a bare key can *always* be classified — reserved vs attribute — without any marker. The `@` |
| 21 | + conveys no information the reserved-set membership doesn't already give the parser, any |
| 22 | + cross-language tool, or a human who knows the set. Once bare keys are accepted, `@` is pure |
| 23 | + ceremony. |
19 | 24 |
|
20 | | -**The problem this ADR fixes:** the language ports never *enforced* disjointness. A reserved |
21 | | -word written with a sigil — `@isArray` — was silently accepted as an inline attribute named |
22 | | -`isArray`. That produced **two incoherent ways to express the same concept**: the structural |
23 | | -`isArray` property (which marks a field as a collection) versus a meaningless attribute that |
24 | | -does *not*. One shared conformance fixture (`origin-collection-simple`) was authored with |
25 | | -`@isArray`, and the TS Python entity-codegen briefly papered over the split by reading array-ness |
26 | | -from *either* form — masking the incoherence rather than resolving it. They cannot both be valid. |
27 | | - |
28 | | -**Prior art** (validates the design, not just the bug fix): |
29 | | -- The closed-keyword-vs-open-extension split with a distinguishing sigil is the **OpenAPI** |
30 | | - model — bare names are spec keywords; `x-`-prefixed names are vendor extensions, which |
31 | | - "must not conflict with existing keywords." MetaObjects mirrors this with the sigil on the |
32 | | - attribute side (`@`) and the structural vocabulary bare. JSON Schema is similar (`$`-core |
33 | | - keywords + ignored/custom keywords). |
34 | | -- `isArray` as a **boolean modifier on a typed field** (rather than array-as-its-own-type) is |
35 | | - the **Protocol Buffers `repeated`** lineage, shared by GraphQL (`[T]`) and TypeScript |
36 | | - (`T[]`). JSON Schema/OpenAPI/Avro instead model array as a distinct type (`type: array` + |
37 | | - `items`). MetaObjects deliberately follows the modifier family: a field has an element |
38 | | - subtype (`field.string`, `field.object` + `@objectRef`) plus a structural `isArray` flag. |
| 25 | +Prior art: Kubernetes, Docker Compose, and GitHub Actions YAML all use **bare keys + a known |
| 26 | +schema, no sigil** — the dominant pattern for schema-backed config. |
39 | 27 |
|
40 | 28 | ## Decision |
41 | 29 |
|
42 | | -1. **Two disjoint namespaces.** A node body contains exactly: reserved structural keywords |
43 | | - (bare) and inline attributes (`@`-prefixed). Nothing else is valid. |
44 | | - |
45 | | -2. **The reserved structural keyword set is closed and fixed:** `name`, `package`, `extends`, |
46 | | - `abstract`, `overlay`, `isArray`, `children`, `value`. (Canonical key order is defined in |
47 | | - `spec/wire-format.md`.) Growing this set is itself an ADR-level change. |
48 | | - |
49 | | -3. **Array-ness is structural.** A field is a collection iff its bare `isArray` reserved |
50 | | - keyword is `true`. This is the sole representation; array-ness is never an attribute. |
51 | | - |
52 | | -4. **`@<reserved-word>` is a hard load error.** Applying the `@` sigil to any reserved |
53 | | - keyword name (e.g. `@isArray`, `@abstract`, `@name`) is a category error — using the |
54 | | - attribute namespace for a structural word — and fails the load with **`ERR_RESERVED_ATTR`**. |
55 | | - This is enforced identically in **every** language port; an error conformance fixture pins it. |
56 | | - |
57 | | -5. **Consumers read the canonical form only.** Codegen, runtime, and migration read array-ness |
58 | | - (and every other structural concept) from the structural keyword/property — never from an |
59 | | - attribute. No "accept either form" compatibility shims. |
| 30 | +1. **Metadata keys are bare. There is no `@` sigil.** Identical in JSON and YAML. |
| 31 | +2. **Reserved structural keywords are a closed, fixed set:** `name`, `package`, `extends`, |
| 32 | + `abstract`, `overlay`, `isArray`, `children`, `value`. (Canonical key order in |
| 33 | + `spec/wire-format.md`. Growing the set is an ADR-level change.) |
| 34 | +3. **Any other bare key is an inline attribute.** Classification is reserved-set membership; the |
| 35 | + per-(type,subType) `AttrSchema` (ADR-0004) still types/validates declared attributes. |
| 36 | +4. **An `@`-prefixed key is rejected** (`ERR_AT_PREFIX_KEY`): "metadata keys are bare; `@x` is not |
| 37 | + valid — write `x`." This catches legacy `@attr` authoring and the `@isArray`-style mistake. |
| 38 | +5. **Arrays** are the bare reserved `isArray` (JSON) or the YAML `[]` key-suffix sugar |
| 39 | + (`field.object[]:` → `isArray: true`) — never a sigiled key. |
60 | 40 |
|
61 | 41 | ## Consequences |
62 | 42 |
|
63 | | -- **Conformance corpus:** |
64 | | - - New shared error code **`ERR_RESERVED_ATTR`** in `fixtures/conformance/ERROR-CODES.json`. |
65 | | - - New error fixture (e.g. `error-reserved-word-as-attr`) asserting `@isArray` → `ERR_RESERVED_ATTR`. |
66 | | - - `origin-collection-simple` corrected from `@isArray` to bare `isArray`; its `expected.json` |
67 | | - regenerated (the field now carries the structural array flag, as intended). |
68 | | -- **Every parser** adds one check in its `@`-key handling: if the de-sigiled name is in the |
69 | | - reserved set, emit `ERR_RESERVED_ATTR` instead of creating an attribute. Small, local, and |
70 | | - identical in spirit across TS / Python / C# / Java. |
71 | | -- **Codegen** drops any "either form" array detection and reads the structural property only. |
72 | | -- **Authoring** gets a loud, early error instead of a silent footgun (a field that *looks* |
73 | | - marked as an array but isn't). |
74 | | -- **Back-compat:** any existing metadata using `@<reserved>` now fails to load. This is |
75 | | - intended — such documents were already semantically wrong. Only the one in-repo fixture is |
76 | | - affected; downstream consumers using the documented bare form are unaffected. |
77 | | - |
78 | | -## YAML (both renderings — "one structure, two renderings") |
79 | | - |
80 | | -JSON and YAML authoring funnel through the **same** `buildTree`/`parser-core`, and the |
81 | | -`ERR_RESERVED_ATTR` check lives there (not in the JSON front-end), so **this rule applies |
82 | | -identically to YAML**. Two YAML specifics: |
83 | | - |
84 | | -- **Arrays are the idiomatic `[]` key-suffix sugar.** The YAML desugar turns `field.object[]:` |
85 | | - into the reserved structural `isArray: true`. So YAML authors express collections with no |
86 | | - sigil and never write `@isArray` — naturally compliant. |
87 | | -- **An `@`-prefixed key must be quoted in YAML** (`"@table": products`), because `@` is a YAML |
88 | | - reserved indicator. This is functional, but across the attr-heavy source-v2 / persistence |
89 | | - designs (`@table`, `@kind`, `@role`, `@onDelete`, …) it's verbose. |
90 | | - |
91 | | -**Open question (deferred, not part of this ADR's core rule):** add a *fifth* YAML desugar rule |
92 | | -so inline attributes may be written **bare** in YAML — the desugar prefixes `@` to any key not |
93 | | -in the reserved set (the set is closed and known to the registry), keeping YAML clean |
94 | | -(`table: products`) while the canonical form keeps the `@` distinction. This would make YAML |
95 | | -first-class-ergonomic without weakening the namespace rule. Flagged for decision; **YAML |
96 | | -conformance fixtures** (the corpus is JSON-only today) should land with it. |
| 43 | +- **Metamodel-wide `@`-purge migration** (mechanical but pervasive, all four ports): |
| 44 | + - Parsers classify a bare non-reserved key as an attribute (replacing the `@`-prefix loop) and |
| 45 | + reject `@`-prefixed keys. |
| 46 | + - Serializers emit attributes bare. |
| 47 | + - Every `expected.json` in the shared corpus is regenerated (all currently carry `@`). |
| 48 | + - All input fixtures, spec docs, and CLAUDE.md examples drop `@`. |
| 49 | +- **The model simplifies:** no sigil, no `@<reserved>` footgun, no special reserved-attr error |
| 50 | + beyond the blanket "no `@` prefix" rule. JSON and YAML become the same (no YAML quoting). |
| 51 | +- **An attribute cannot be named a reserved word** (it would *be* the reserved key) — the same |
| 52 | + constraint the old `@<reserved>`-ban imposed, now structural. |
| 53 | +- **The canonical/wire form loses the explicit attribute marker.** Consumers rely on the closed, |
| 54 | + known reserved set (which every port already has). Judged an acceptable, marginal loss. |
| 55 | +- **Back-compat:** documents using `@attr` stop loading (rejected). Intended — the corpus is |
| 56 | + migrated, and the metamodel is pre-1.0. |
97 | 57 |
|
98 | 58 | ## Alternatives considered |
99 | 59 |
|
100 | | -- **Normalize silently** (`@isArray` → bare `isArray`). Rejected: it keeps two accepted |
101 | | - spellings for one concept, which is the incoherence we are removing; `@` would no longer be |
102 | | - *exclusively* the attribute namespace. |
103 | | -- **Warn and normalize.** Rejected: softer, but still blesses the wrong form and complicates |
104 | | - the canonical contract with a warning. The rule is simple enough to enforce strictly. |
105 | | -- **Array as a distinct type** (JSON-Schema style `type: array` + items). Rejected: MetaObjects |
106 | | - is committed to the typed-field-plus-modifier model (Protobuf `repeated` lineage); reworking |
107 | | - it would be a far larger remodel for no expressive gain here. |
| 60 | +- **Keep `@` on attributes (original).** Clean in JSON, hostile in YAML, and — once bare keys are |
| 61 | + accepted — redundant ceremony. Rejected. |
| 62 | +- **Keep `@` in JSON, bare in YAML (per-format).** Inconsistent across the two renderings of one |
| 63 | + structure for no gain. Rejected. |
| 64 | +- **Keep `@` everywhere but also accept bare.** The half-measure that exposed the redundancy: |
| 65 | + if bare works, `@` is strippable, so just strip it. Rejected in favor of fully purging. |
| 66 | +- **`_`-prefix on reserved words (bare attrs).** YAML-safe and keeps a visual cue, but it marks |
| 67 | + the *frequent* set (`name`/`children` on every node) and uglifies the canonical form. Rejected. |
108 | 68 |
|
109 | 69 | ## Realization status |
110 | 70 |
|
111 | | -- **Spec:** `spec/wire-format.md` already states the rule; this ADR elevates it to an enforced, |
112 | | - cross-cutting contract and adds the `ERR_RESERVED_ATTR` enforcement requirement. |
113 | | -- **TypeScript:** _pending this change._ |
114 | | -- **Python:** _pending this change._ |
115 | | -- **C#:** _pending this change._ |
116 | | -- **Java:** _pending this change._ |
| 71 | +- **Spec:** `spec/wire-format.md` updated to bare keys. |
| 72 | +- **TypeScript / Python / C# / Java:** _pending_ — the `@`-purge rides the source-v2 rollout |
| 73 | + (shared corpus + TS reference first), since both touch the parser/serializer + the whole corpus. |
0 commit comments