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
21 changes: 21 additions & 0 deletions agent-context/skills/metaobjects-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ concept (`meta.commerce.json`, `meta.users.yaml`, …). Each file declares a
`package` on its root node. Files in the same `package` with the same object
`name` are merged by the loader.

## Find the construct first — model it, don't hand-write it

You do **not** know the full vocabulary from memory, and it is larger and more
powerful than the basics below. Before authoring anything non-trivial, and the
moment you reach for hand-written data logic, **search the live metamodel**:

```
meta types relationship # find by name
meta types --all aggregate # find by WHAT IT DOES (searches descriptions)
meta types origin.aggregate --detail # one construct: description, when to use it, valid @attrs
```

**The rule:** before you hand-write any data logic — a join, a foreign key, a
derived/aggregate value (count/sum/avg), a uniqueness / format / range / cross-field
rule, a relationship between entities, a derived read model — run `meta types` and
check for a construct that **declares** it. If one exists, declare it instead. That
is the entire point of MetaObjects: declared metadata is generated, typed, and
regenerates on change; hand-written logic drifts and is the thing this tool exists
to eliminate. When you catch yourself writing a query, a validator, or an FK by
hand, stop and search the types first.

Two on-disk formats, one shape:

- **Canonical JSON** — the on-disk interchange. Every node is a single-key map
Expand Down
1 change: 1 addition & 0 deletions docs/features/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ command surface splits in two:
| **Schema drift** (`verify --db`) | **Node `meta`** | `meta verify --db` | **any backend** — live-DB drift, Node-only |
| **Codegen drift** (`verify --codegen`) | **Node `meta`** | `meta verify --codegen` | TS reference (ADR-0021 D2) — regen-to-temp + diff committed output |
| **Template/prompt drift** (`verify --templates`) | **Node `meta`** | `meta verify --templates` | TS reference (ADR-0021 D2) — `{{field}}`↔payload; the bare-`verify` default |
| **Vocabulary search** (`types`) | **Node `meta`** | `meta types [query]` | **any backend** — apropos/`kubectl explain` over the live metamodel registry (names + descriptions + when-to-use); the vocabulary is cross-port identical (registry-conformance) |
| TS codegen | Node `meta` | `meta gen` | TS projects |
| C# codegen | `dotnet meta` | `dotnet meta gen` / `verify --templates` / `verify --codegen` | a .NET tool (`ToolCommandName=dotnet-meta`); invoked `dotnet meta` so it never shadows the Node `meta`; ships the ADR-0021 D2 subverbs (`--db` rejected, exit 2; bare `verify` = `--templates`) |
| Java/Kotlin codegen | Maven plugin | `mvn metaobjects:generate` (`meta:gen`) | Kotlin generators run through the same goal — see below |
Expand Down
37 changes: 37 additions & 0 deletions fixtures/registry-conformance/expected-registry.json

Large diffs are not rendered by default.

27 changes: 20 additions & 7 deletions server/csharp/MetaObjects/SpecMetamodel/field.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"dataType": "string",
"extendsBase": true,
"description": "Variable-length text. Binds to the native string type; DB column is VARCHAR/TEXT (use @maxLength for VARCHAR(n)).",
"whenToUse": "Plain variable-length text. Set @maxLength to size the column. The default for textual data.",
"children": [
{ "type": "attr", "subType": "int", "name": "maxLength", "min": 0, "max": 1, "description": "Maximum character length for string-typed fields (drives VARCHAR(n))." }
]
Expand All @@ -31,21 +32,24 @@
"subType": "int",
"dataType": "int",
"extendsBase": true,
"description": "32-bit signed integer. Binds to the native int type; DB column is INTEGER."
"description": "32-bit signed integer. Binds to the native int type; DB column is INTEGER.",
"whenToUse": "A whole number within +/-2^31. Use long instead if values can exceed that."
},
{
"type": "field",
"subType": "long",
"dataType": "long",
"extendsBase": true,
"description": "64-bit signed integer. Binds to the native long/bigint type; DB column is BIGINT."
"description": "64-bit signed integer. Binds to the native long/bigint type; DB column is BIGINT.",
"whenToUse": "A whole number that may exceed 32 bits (ids, counters, epoch millis)."
},
{
"type": "field",
"subType": "double",
"dataType": "double",
"extendsBase": true,
"description": "Double-precision (64-bit) IEEE-754 floating point. Binds to the native double/number type; DB column is DOUBLE PRECISION. Not for money — use field.currency or field.decimal."
"description": "Double-precision (64-bit) IEEE-754 floating point. Binds to the native double/number type; DB column is DOUBLE PRECISION. Not for money — use field.currency or field.decimal.",
"whenToUse": "An approximate floating-point number where exactness is not required. For money/precision use decimal."
},
{
"type": "field",
Expand All @@ -60,6 +64,7 @@
"dataType": "string",
"extendsBase": true,
"description": "Precision-exact decimal (use @precision/@scale). Native TS binding is string (lossless); DB column is NUMERIC(p,s); the wire form is a string. Classified DATA_TYPE_STRING so an exact decimal is never silently rounded through a double.",
"whenToUse": "A value needs exact precision (money amounts, rates, quantities). Use decimal with @precision/@scale — never double, which loses precision.",
"rules": "The wire and native-TS form is a STRING to stay precision-exact end-to-end (Drizzle pg numeric infers as string; SP-H/ADR-0019). Set @precision (total significant digits) and @scale (digits right of the point) to drive NUMERIC(p,s).",
"children": [
{ "type": "attr", "subType": "int", "name": "precision", "min": 0, "max": 1, "description": "Total number of significant digits for decimal-typed fields." },
Expand All @@ -71,14 +76,16 @@
"subType": "boolean",
"dataType": "boolean",
"extendsBase": true,
"description": "True/false flag. Binds to the native boolean type; DB column is BOOLEAN."
"description": "True/false flag. Binds to the native boolean type; DB column is BOOLEAN.",
"whenToUse": "A true/false flag."
},
{
"type": "field",
"subType": "date",
"dataType": "date",
"extendsBase": true,
"description": "Calendar date (no time-of-day). Binds to the native date/temporal type; DB column is DATE."
"description": "Calendar date (no time-of-day). Binds to the native date/temporal type; DB column is DATE.",
"whenToUse": "A column is a calendar date with no time-of-day. Use date instead of a string."
},
{
"type": "field",
Expand All @@ -92,14 +99,16 @@
"subType": "timestamp",
"dataType": "date",
"extendsBase": true,
"description": "Date + time-of-day instant (optionally with timezone). Binds to the native date/temporal type; DB column is TIMESTAMP(TZ). Pair with @autoSet for created/updated stamping."
"description": "Date + time-of-day instant (optionally with timezone). Binds to the native date/temporal type; DB column is TIMESTAMP(TZ). Pair with @autoSet for created/updated stamping.",
"whenToUse": "A column records an instant (created/updated at). Use timestamp so it serializes ISO-8601 with timezone consistently."
},
{
"type": "field",
"subType": "object",
"dataType": "object",
"extendsBase": true,
"description": "A nested structured value (set @objectRef to the target object). Storage is governed by @storage: flattened (prefixed columns), jsonb (single jsonb column, supports isArray), or subdocument (document-store hint).",
"whenToUse": "A field holds a nested structured value (or an array of them). Set @objectRef + @storage so the shape is typed and persisted (flattened/jsonb) instead of an untyped blob.",
"rules": "Set @objectRef to the nested object's name (or FQN). @storage selects physical layout — flattened expands into prefixed parent columns (isArray must be false), jsonb stores the structured value (or array when isArray=true) in one jsonb column, subdocument emits no Postgres column. Defaults to single-jsonb-column when @storage is absent.",
"children": [
{ "type": "attr", "subType": "string", "name": "objectRef", "min": 0, "max": 1, "description": "Name (or FQN) of the target object an object-typed field nests — drives nested-object (de)serialization." }
Expand All @@ -111,6 +120,7 @@
"dataType": "object",
"extendsBase": true,
"description": "An open-keyed map (Record<string,V> / dict[str,V]) stored in a single jsonb column. Keys are always strings (the JSON object constraint); the value type is set by @valueType (a scalar field subtype) or @objectRef (a value-object).",
"whenToUse": "A field is an open-keyed map of values (Record<string,V>). Use it for dynamic keys instead of an untyped jsonb string.",
"rules": "Keys are always strings. Set exactly one of @valueType (a scalar value subtype: string/int/long/double/float/decimal/boolean/date/time/timestamp/uuid) or @objectRef (a value-object name or FQN). Stored as a single jsonb column holding the JSON object — never a native array; isArray does not apply.",
"children": [
{ "type": "attr", "subType": "string", "name": "valueType", "min": 0, "max": 1, "description": "Scalar value subtype for a scalar-valued map (string/int/long/double/float/decimal/boolean/date/time/timestamp/uuid). Mutually exclusive with @objectRef; exactly one of the two must be set." },
Expand All @@ -123,6 +133,7 @@
"dataType": "long",
"extendsBase": true,
"description": "Stores money as integer minor units (cents). Binds to long; the client formats via @currency/@locale. Float arithmetic for money is forbidden.",
"whenToUse": "A column holds money. Use currency so it stores integer minor-units and the client formats it — never floats or hand-rolled cents math.",
"rules": "Storage is integer minor units (cents for USD, yen for JPY) — the wire form is unchanged from long. The server never formats currency; all formatting is client-side via Intl.NumberFormat using @currency (ISO 4217) and @locale (BCP 47). Float arithmetic for money is forbidden.",
"children": [
{ "type": "attr", "subType": "string", "name": "currency", "min": 0, "max": 1, "default": "USD", "description": "ISO 4217 currency code for a currency-subtype field. Storage is integer minor units; defaults to 'USD' when omitted." }
Expand All @@ -134,6 +145,7 @@
"dataType": "string",
"extendsBase": true,
"description": "String-backed enumeration constrained to a closed set of member symbols (@values). Each member is its own stored string with no name/value divergence.",
"whenToUse": "A field is a fixed, closed set of string values. Set @values so the union type, DB CHECK, and validation are generated — don't hand-roll constants + checks.",
"rules": "Required @values is a non-empty, duplicate-free set; each member must match ^[A-Za-z_][A-Za-z0-9_]*$ so symbol == stored string in every target language. Optional FR-010/FR-011 overlays add tolerant-extract aliasing (@enumAlias), per-member docs (@enumDoc), an uncoercible-value fallback (@coerceDefault, must be one of @values), and ASCII normalization mode (@normalize).",
"children": [
{ "type": "attr", "subType": "string", "name": "values", "isArray": true, "min": 1, "max": 1, "description": "Member symbols of an enum-subtype field. Declaration order is significant; each is a legal identifier and its own stored string." },
Expand All @@ -145,7 +157,8 @@
"subType": "uuid",
"dataType": "string",
"extendsBase": true,
"description": "Logical UUID identity scalar. A bare scalar (no required attrs, no loader value-validation) — binds to TS string (no native UUID type); DB column is Postgres-native uuid."
"description": "Logical UUID identity scalar. A bare scalar (no required attrs, no loader value-validation) — binds to TS string (no native UUID type); DB column is Postgres-native uuid.",
"whenToUse": "A key or external identifier is a UUID. Use it for a typed UUID column instead of a plain string."
}
]
}
3 changes: 3 additions & 0 deletions server/csharp/MetaObjects/SpecMetamodel/identity.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "identity",
"subType": "primary",
"description": "The primary key — one per entity; @fields names its column(s), @generation the value strategy.",
"whenToUse": "Every entity needs exactly one — names the primary-key field(s) and how the value is generated. Always declare it.",
"maxOccurs": 1,
"defaultName": "primary",
"children": [
Expand All @@ -16,6 +17,7 @@
"type": "identity",
"subType": "secondary",
"description": "A secondary index (unique by default via @unique).",
"whenToUse": "A column or set must be unique, or you want an index for lookups/sorting. Declare it instead of a hand-written UNIQUE constraint or CREATE INDEX.",
"children": [
{ "type": "attr", "subType": "string", "name": "fields", "isArray": true, "min": 1, "max": 1, "description": "The field name(s) composing this identity. Single-element for a simple PK/index, multiple for a composite." },
{ "type": "attr", "subType": "boolean", "name": "unique", "min": 0, "max": 1, "description": "When true (default), the secondary identity is a UNIQUE index; false makes it a plain (non-unique) index." }
Expand All @@ -25,6 +27,7 @@
"type": "identity",
"subType": "reference",
"description": "A foreign-key reference to another entity (@references target; @enforce toggles a physical FK).",
"whenToUse": "This entity holds a foreign key to another. Declare it to generate the FK constraint + typed navigation, instead of a loose id field you join on by hand.",
"children": [
{ "type": "attr", "subType": "string", "name": "fields", "isArray": true, "min": 1, "max": 1, "description": "The field name(s) composing this identity. Single-element for a simple PK/index, multiple for a composite." },
{ "type": "attr", "subType": "string", "name": "references", "min": 1, "max": 1, "description": "Target of the reference. Bare entity name (e.g. 'Program') resolves to that entity's primary identity. Dotted forms ('Program.id' or 'Program.fieldA,fieldB') target an explicit field set on the entity." },
Expand Down
3 changes: 3 additions & 0 deletions server/csharp/MetaObjects/SpecMetamodel/object.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"subType": "entity",
"extendsBase": true,
"description": "An object that owns its data: own identity, writable sources, and lifecycle. The default object subtype — a bare `object:` key resolves to entity. May co-locate templates (template.prompt and friends) with the owning entity.",
"whenToUse": "The thing owns its own data and lifecycle — a table you create/read/update/delete. The default for any persisted resource.",
"rules": "object.entity owns data — it declares its own identity, its primary source must be a writable @kind (read-only kinds may appear only in a read role), and it carries lifecycle. A field carrying origin.* is derived ⇒ read-only wherever it lives, including on an entity. Templates (template.*) may be nested so a prompt can be co-located with its owning entity. See ADR-0028 (object taxonomy).",
"children": [
{ "type": "relationship", "subType": "*", "name": "*", "min": 0, "max": null },
Expand All @@ -31,6 +32,7 @@
"subType": "value",
"extendsBase": true,
"description": "A value object — pure shape with NO identity and NO source, ever. Constructed (by caller / assembly / embedding), never populated from a store. May `extends` an entity's fields to reuse shape. Equality is by content.",
"whenToUse": "You need a reusable typed shape with NO identity and NO table — an embedded value, a DTO, a prompt/response payload. Constructed in memory, never persisted on its own.",
"rules": "object.value is pure shape: it NEVER declares an identity and NEVER declares a source, in any role. It is constructed — by a caller, by assembly, or by embedding — and is never populated from a backing store. It may `extends` an entity's fields to reuse their shape. @normalize is the object-level default ASCII normalization mode applied to this value's enum fields' tolerant extract (each field may still override per-field). See ADR-0028 (object taxonomy, value purity).",
"children": [
{ "type": "relationship", "subType": "*", "name": "*", "min": 0, "max": null }
Expand All @@ -41,6 +43,7 @@
"subType": "projection",
"extendsBase": true,
"description": "A derived read-only representation of entities. Its fields are extends-bound / origin-derived / self-declared-under-external-assembly, all read-only at the subtype level. Identity is optional and MUST extend an entity identity; sources are restricted to read-only @kinds. The declared field set IS the exposure (inclusive, fail-closed).",
"whenToUse": "You need a derived, read-only view over entities — a report, summary, or joined/aggregated read model. Declare it (with origin.* children) instead of hand-writing the SELECT/joins; it stays read-only and regenerates.",
"rules": "object.projection is a derived read-only representation: every field is extends-bound, origin-derived, or self-declared-under-external-assembly, and all are read-only at the subtype level. Identity is optional and, when present, MUST extend an entity identity. Sources are restricted to read-only @kinds. The declared field set IS the exposure — an inclusive list, fail-closed. A projection NEVER declares relationships (derivation is expressed via @via, not a relationship child) and NEVER co-locates templates — hence its child set omits both relationship and template. See ADR-0028 (object taxonomy, projection)."
}
]
Expand Down
3 changes: 3 additions & 0 deletions server/csharp/MetaObjects/SpecMetamodel/origin.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"type": "origin",
"subType": "passthrough",
"description": "A cross-entity field reference: this projection field passes a source entity's value straight through (@from), optionally reached via a relationship path (@via).",
"whenToUse": "A projection field just surfaces a field from a related entity. Declare the cross-entity passthrough instead of re-joining and re-selecting it by hand.",
"children": [
{ "type": "attr", "subType": "string", "name": "from", "min": 1, "max": 1, "description": "Dotted Entity.field reference identifying the source value this projection field passes through (e.g. 'Program.title')." },
{ "type": "attr", "subType": "string", "name": "via", "min": 0, "max": 1, "description": "Optional dotted relationship path used to reach the source entity (e.g. 'Program.weeks')." }
Expand All @@ -19,6 +20,7 @@
"type": "origin",
"subType": "aggregate",
"description": "A count/sum/avg/min/max (@agg) computed over a column (@of) reached along a relationship path (@via) from the base entity.",
"whenToUse": "A projection needs a derived count/sum/avg/min/max over related rows. Declare it instead of hand-writing the aggregate query — it stays consistent and regenerates.",
"rules": "@via may be omitted only when exactly one single-hop relationship leads from the base entity to the @of entity (single-hop-unique inference; FR-024, ADR-0029). Multi-hop paths must always be stated explicitly.",
"children": [
{ "type": "attr", "subType": "string", "name": "agg", "min": 1, "max": 1, "allowedValues": ["count", "sum", "avg", "min", "max"], "description": "Aggregate function applied over the relationship path: count, sum, avg, min, or max." },
Expand All @@ -30,6 +32,7 @@
"type": "origin",
"subType": "collection",
"description": "A relationship-derived array of nested view-objects: walks @via to produce the collection (e.g. 'Author.posts'), or a wildcard selector for a package-spanning collection.",
"whenToUse": "A projection needs an array of nested child view-objects (a parent with its children inline). Declare it instead of hand-assembling the nested query + mapping.",
"children": [
{ "type": "attr", "subType": "string", "name": "via", "min": 1, "max": 1, "description": "Dotted relationship path the collection walks to produce an array of nested view-objects (e.g. 'Author.posts'), or a wildcard selector for a package-spanning collection (e.g. '*.User')." }
]
Expand Down
Loading
Loading