Skip to content

Commit 52f7617

Browse files
committed
test(agent-context): conformance corpus + runner (2 stacks, byte-exact + selection)
1 parent 2341742 commit 52f7617

28 files changed

Lines changed: 2683 additions & 0 deletions

File tree

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
---
2+
name: metaobjects-authoring
3+
description: Use when authoring or modifying MetaObjects metadata — fields, entities, relationships, sources, enums, abstracts/inheritance — in YAML or canonical JSON.
4+
---
5+
6+
# Authoring MetaObjects metadata
7+
8+
MetaObjects metadata is the durable spine of your app: typed entity declarations
9+
that drive code generation, runtime behavior, and drift detection. You author it,
10+
the loader reads it, the codegen emits idiomatic per-language code from it. This
11+
skill is the procedure for writing it correctly.
12+
13+
Metadata lives in files under `metaobjects/` at project root, one file per domain
14+
concept (`meta.commerce.json`, `meta.users.yaml`, …). Each file declares a
15+
`package` on its root node. Files in the same `package` with the same object
16+
`name` are merged by the loader.
17+
18+
Two on-disk formats, one shape:
19+
20+
- **Canonical JSON** — the on-disk interchange. Every node is a single-key map
21+
whose key fuses the type and subtype.
22+
- **YAML** — the sigil-free authoring front-end. Lowered to canonical JSON at load
23+
time, so it shares the entire downstream pipeline.
24+
25+
Author in whichever fits the project. Prefer YAML for new hand-authored metadata
26+
(it's less noisy); JSON is the format conformance fixtures and tooling pin.
27+
28+
## The fused-key encoding (non-negotiable)
29+
30+
Every node is `{ "<type>.<subType>": { <body> } }`. The wrapper key fuses type and
31+
subtype — there is **no** separate `subType` body key.
32+
33+
```json
34+
{ "object.entity": { "name": "User" } }
35+
{ "field.string": { "name": "email", "@required": true } }
36+
{ "field.enum": { "name": "status", "@values": ["OPEN", "CLOSED"] } }
37+
{ "identity.primary": { "@fields": ["id"] } }
38+
```
39+
40+
A complete entity in canonical JSON:
41+
42+
```json
43+
{
44+
"metadata.root": {
45+
"package": "acme::blog",
46+
"children": [
47+
{
48+
"object.entity": {
49+
"name": "Author",
50+
"children": [
51+
{ "source.rdb": { "@table": "authors" } },
52+
{ "field.long": { "name": "id" } },
53+
{ "field.string": { "name": "name", "@required": true, "@maxLength": 200 } },
54+
{ "field.string": { "name": "bio", "@maxLength": 2000 } },
55+
{ "identity.primary": { "@fields": ["id"], "@generation": "increment" } }
56+
]
57+
}
58+
}
59+
]
60+
}
61+
}
62+
```
63+
64+
The same entity in sigil-free YAML:
65+
66+
```yaml
67+
metadata:
68+
package: acme::blog
69+
children:
70+
- object.entity:
71+
name: Author
72+
children:
73+
- source.rdb: { table: authors }
74+
- field.long: { name: id }
75+
- field.string: { name: name, required: true, maxLength: 200 }
76+
- field.string: { name: bio, maxLength: 2000 }
77+
- identity.primary: { fields: id, generation: increment }
78+
```
79+
80+
## Reserved structural keys vs. attributes
81+
82+
There is one closed set of **reserved structural keys**. Everything else is an
83+
attribute.
84+
85+
```
86+
name package extends abstract overlay isArray children value
87+
```
88+
89+
- In **canonical JSON**: reserved keys are bare (`"name"`, `"extends"`); every
90+
other key is `@`-prefixed (`"@required"`, `"@maxLength"`, `"@table"`).
91+
- In **YAML**: reserved keys are bare AND attributes are bare too — the desugar
92+
re-adds the `@` when lowering.
93+
- `@`-prefixing a reserved word (e.g. `"@isArray": true`) is invalid and fails the
94+
load with `ERR_RESERVED_ATTR`. Use the bare `isArray: true` (YAML) or the `[]`
95+
key-suffix sugar (`field.long[]: weekIds`).
96+
97+
## Two violation rules — internalize these
98+
99+
1. **Attribute-name uniqueness within a node.** A node body must not declare the
100+
same attribute name twice. `{ "field.string": { "name": "x", "@maxLength": 10,
101+
"@maxLength": 20 } }` is malformed.
102+
103+
2. **An inline `@attr` IS an `attr` child — never both.** An inline attribute and
104+
a child `attr.*` node with the same name are the same slot expressed two ways.
105+
Declare a given attribute once, in one form. Don't set `@required` inline AND
106+
also add an `attr.boolean` child named `required` — that's a double-declaration.
107+
108+
## Field subtypes (closed vocabulary)
109+
110+
| Subtype | Stores | Notes |
111+
|---|---|---|
112+
| `field.string` | text | `@maxLength` drives `varchar(N)` |
113+
| `field.int` | 32-bit integer | |
114+
| `field.long` | 64-bit integer | |
115+
| `field.double` | float | |
116+
| `field.boolean` | true/false | |
117+
| `field.date` | calendar date | ISO 8601 `YYYY-MM-DD` on the wire |
118+
| `field.timestamp` | instant | ISO 8601 with timezone on the wire |
119+
| `field.decimal` | exact decimal | `@precision` / `@scale`; lossless money/quantity |
120+
| `field.currency` | integer minor units | see Currency below |
121+
| `field.enum` | string member | `@values` required; see Enum below |
122+
| `field.uuid` | UUID | canonical lowercase hex on the wire |
123+
| `field.object` | embedded value object | `@objectRef` + `@storage`; see below |
124+
125+
Common field attributes: `@required`, `@maxLength`, `@column` (physical column
126+
name), `@default`, `@filterable`, `@sortable`.
127+
128+
### Currency
129+
130+
`field.currency` stores money as **integer minor units** (cents for USD, yen for
131+
JPY) — never a float. `@currency` is ISO 4217; `@locale` (on a `view.currency`
132+
child) is BCP 47. The server never formats currency; formatting is client-side.
133+
134+
```json
135+
{ "field.currency": {
136+
"name": "priceCents", "@currency": "USD", "@required": true,
137+
"children": [ { "view.currency": { "@locale": "en-US" } } ]
138+
}}
139+
```
140+
141+
### Enum
142+
143+
`field.enum` is string-backed. `@values` is **required**: a non-empty set of
144+
unique members, each matching `^[A-Za-z_][A-Za-z0-9_]*$`. Missing `@values`
145+
`ERR_MISSING_REQUIRED_ATTR`; a bad member → `ERR_BAD_ATTR_VALUE`.
146+
147+
```json
148+
{ "field.enum": { "name": "status", "@required": true,
149+
"@values": ["DRAFT", "PUBLISHED", "ARCHIVED"] } }
150+
```
151+
152+
Reuse a constraint set across entities with an abstract `field.enum` + `extends`.
153+
154+
### Embedded value objects — `field.object` + `@storage`
155+
156+
`field.object` embeds another `object` declaration. `@objectRef` names it;
157+
`@storage` controls persistence:
158+
159+
- `flattened` — one DB column per sub-field (`address_street`, `address_city`, …).
160+
Illegal on array fields.
161+
- `jsonb` — one `jsonb` column.
162+
- `subdocument` (default, back-compat) — single jsonb column.
163+
164+
```json
165+
{ "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } }
166+
```
167+
168+
## YAML sigil-free authoring + the coercion footgun
169+
170+
In YAML, write the fused `type.subType` key with a **map body**, bare reserved
171+
keys, bare attributes. Two house-style rules:
172+
173+
1. **Always write the explicit `type.subType`** (`field.string`, not `field`).
174+
Defaults change; the explicit form survives registry edits.
175+
176+
2. **Quote any scalar that looks like a boolean, number, date, or null.** YAML
177+
silently coerces unquoted `yes` / `no` / `on` / `off` to booleans and bare
178+
`2026-05-25` to a date. The loader's coercion guard rejects a coerced value in
179+
a slot that declares a different type (`ERR_YAML_COERCION`) — but quoting is how
180+
you *prevent* the surprise. Enum members are the classic trap:
181+
182+
```yaml
183+
# Rejected — Y and N coerce to booleans
184+
field.enum: { name: flag, values: [Y, N] }
185+
# Correct — quote domain-data members
186+
field.enum: { name: flag, values: ["Y", "N"] }
187+
```
188+
189+
The `[]` key-suffix declares an array field: `field.long[]: weekIds` lowers to
190+
`{ "field.long": { "name": "weekIds", "isArray": true } }`.
191+
192+
## Identities
193+
194+
| Subtype | Purpose | Key attrs |
195+
|---|---|---|
196+
| `identity.primary` | the PK field(s) | `@fields`, `@generation` |
197+
| `identity.secondary` | a unique secondary index | `@fields` |
198+
| `identity.reference` | an inbound FK from this entity to another | `@fields`, `@references`, `@enforce` |
199+
200+
`@generation` on a primary controls value generation (e.g. `increment`).
201+
`@fields` accepts a single string in authoring; it normalizes to an array in
202+
canonical JSON. `@enforce` on a reference (default `true`) controls whether the
203+
backend physically enforces it (a SQL FK constraint); set `false` for a logical
204+
reference for navigation/typing/codegen only. Referential actions
205+
(`@onDelete`/`@onUpdate`) are NOT on `identity.reference` — they live on the
206+
`relationship.*` node (see Relationships below).
207+
208+
```json
209+
{ "identity.primary": { "@fields": ["id"], "@generation": "increment" } }
210+
{ "identity.secondary": { "@fields": ["email"] } }
211+
{ "identity.reference": { "name": "fkAuthor", "@fields": ["authorId"], "@references": "Author", "@enforce": true } }
212+
```
213+
214+
## Relationships
215+
216+
`relationship.composition` is the "this entity owns / aggregates instances of
217+
that entity" side; `identity.reference` (above) is the FK-column side. They are
218+
the two halves of one FK.
219+
220+
| Attr | On | Values |
221+
|---|---|---|
222+
| `@objectRef` | composition | target entity name |
223+
| `@cardinality` | composition | `one` / `many` |
224+
| `@onDelete` / `@onUpdate` | `relationship.*` only | `cascade` / `set-null` / `restrict` / `no-action` |
225+
226+
```json
227+
{ "relationship.composition": {
228+
"name": "posts", "@objectRef": "Post",
229+
"@cardinality": "many", "@onDelete": "cascade" } }
230+
```
231+
232+
## Sources — `source.rdb` + `@kind`
233+
234+
`source.rdb` declares where an entity's data lives. Read-only-ness derives from
235+
`@kind` (it is NOT a separate subtype):
236+
237+
| `@kind` | Read-only | Default? |
238+
|---|---|---|
239+
| `table` | no | yes (when `@kind` omitted) |
240+
| `view` | yes | – |
241+
| `materializedView` | yes | – |
242+
| `storedProc` | yes | – |
243+
| `tableFunction` | yes | – |
244+
245+
The physical name is `@table` (NOT `@name`). The physical column name on a field
246+
is `@column`. `@schema` namespaces the DB schema (Postgres default `public`;
247+
SQLite rejects non-default values). Multi-source: multiple `source.rdb` children,
248+
each with a `@role`, exactly one `primary`.
249+
250+
```json
251+
{ "source.rdb": { "@kind": "view", "@table": "v_author", "@schema": "blog" } }
252+
```
253+
254+
A `view`-kind entity's fields carry `origin.*` children (`passthrough` /
255+
`aggregate` / `collection`) declaring where each value comes from.
256+
257+
## Abstracts + `extends` (deferred resolution) + `overlay`
258+
259+
An **abstract** node (`abstract: true`) describes a shape but is never emitted as
260+
a concrete entity. A concrete node references it via `extends:` to inherit its
261+
children + attrs. This is the lightest reuse mechanism — pure data, no codegen
262+
change.
263+
264+
```yaml
265+
- object.entity:
266+
name: BaseEntity
267+
abstract: true
268+
children:
269+
- field.long: { name: id }
270+
- field.timestamp: { name: createdAt, required: true }
271+
272+
- object.entity:
273+
name: Author
274+
extends: BaseEntity
275+
children:
276+
- source.rdb: { table: authors }
277+
- field.string: { name: name, required: true }
278+
- identity.primary: { fields: id }
279+
```
280+
281+
Resolution facts:
282+
283+
- **Deferred.** `extends:` resolves *after all files load* — abstracts can live in
284+
any file, forward references are fine.
285+
- **Multi-level chains flatten** (`Author extends BaseEntity extends Auditable`).
286+
- **Cross-package** refs use the fully-qualified name (`extends: "shared::auditable"`);
287+
same-package refs use the bare name.
288+
- An unresolved reference fails with `ERR_UNKNOWN_EXTENDS`.
289+
290+
`abstract` and `extends` are **structural keys** (bare, no `@`).
291+
292+
**`overlay` is a different concept.** `extends:` is an IS-A relationship between
293+
two distinct nodes. `overlay: true` re-opens the *same* named node to amend it
294+
across files (same `package` + same `name` → merged; last-writer-wins on attr
295+
conflicts, structural children accumulate). Use `extends` to share shape between
296+
distinct entities; use `overlay` to split one entity's declaration across files.
297+
298+
---
299+
300+
For non-trivial schema design, use `/superpowers:brainstorming` if installed;
301+
otherwise proceed.

0 commit comments

Comments
 (0)