From e97615f3911ec0fb4e6f7585000223b47a0cd958 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 30 Jun 2026 08:46:44 -0400 Subject: [PATCH 1/2] =?UTF-8?q?docs(adr):=20ADR-0037=20refinement=20?= =?UTF-8?q?=E2=80=94=20behavior-based=20subtype=20test=20+=20@kind=20chart?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes the vocabulary-expansion framework so it transfers to ANY future decision, not just the cases that prompted it: - The guiding question is SEMANTIC BEHAVIOR, not surface storage: ask what X DOES, not whether it's a string. - Subtype test is now behavioral: a concept is a subtype if it has its own native type OR behavior OR attributes (the extension point for custom logic) — not merely 'distinct native type'. - @kind chartered as the ONE structural-variant-within-a-subtype axis (source table/view, uri url/urn); never a catch-all, never on a plain string, never swallowing native-type-distinct concepts / boolean flags / validation. - The 'string formats' set splits by behavior: url/uri→field.uri, ip→field.inet (native types + behavior), only email/hostname→@stringFormat (plain validated strings); uuid was already field.uuid. ADR-0036 decision #2 updated to the split (uri/inet subtypes + @stringFormat email/hostname). CLAUDE.md Design Judgment updated. Agent-context decision docs generalized in a follow-on commit on this branch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n --- CLAUDE.md | 2 +- ...tamodel-vocabulary-finalization-for-1.0.md | 10 ++- ...vocabulary-expansion-decision-framework.md | 73 +++++++++++-------- 3 files changed, 53 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0c69edfcc..e1a87326d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -465,7 +465,7 @@ Preserve the following contracts exactly across all language ports: These are the load-bearing principles that have emerged through implementation. Apply them every time. -- **Expanding the metamodel vocabulary follows ONE decision procedure (ADR-0037).** Before adding ANY type/subtype/attribute, run the ordered test: (0) **derivable** from existing subtype + attrs (`isArray`/`@maxLength`) + structure? → derive in codegen, add nothing; (1) **physical-only** (native type/meaning unchanged)? → the `@dbColumnType` escape hatch, not first-class vocab; (2) logical — **different KIND of value** (distinct native type, e.g. UUID→`UUID`/`Guid`) → **subtype**, vs **same kind + orthogonal modifier** (still the base type, just constrained/configured) → **attribute**. Format rule: a string-shape with the native type unchanged (email/url) is a `@format` **attribute**; a distinct native type (uuid) is a **subtype** — don't be fooled by JSON-world tools that call uuid a "format" (JS has no native UUID type). Base type + semantic tag beats type proliferation; orthogonal modifiers live on the base type (`@localTime` on `field.timestamp`, not `field.localDateTime`). This procedure is the antidote to ad-hoc subtype/attribute/format calls — consult it every time. See [ADR-0037](spec/decisions/ADR-0037-metamodel-vocabulary-expansion-decision-framework.md). +- **Expanding the metamodel vocabulary follows ONE decision procedure (ADR-0037) — driven by semantic behavior, not surface storage.** Don't ask "is X a string/number/date?"; ask "what does X *do*?" Ordered test: (0) **derivable** from existing subtype + attrs (`isArray`/`@maxLength`) + structure? → derive in codegen, add nothing; (1) **physical-only** (native type/meaning unchanged)? → the `@dbColumnType` escape hatch, not first-class vocab; (2) logical — **does X have its own native type, behavior, or attributes** (a *thing* that owns custom logic)? → **subtype** (the extension point: `field.uuid`, `field.uri`, `field.inet`); a **structural variant *within* a subtype** (changes generated shape, shares native type)? → **`@kind`** (the one chartered structural-variant axis: source table/view, uri url/urn — never a catch-all, never on a plain string); otherwise X just **modifies/validates/configures** an existing type → **attribute** (boolean flag `@localTime`; validation `@stringFormat` email/hostname; config `@maxLength`). The "string formats" set splits by behavior: url/uri→`field.uri`, ip→`field.inet` (native types + behavior), only email/hostname→`@stringFormat` (plain validated strings); uuid is already `field.uuid`. Self-documentation over economy; no same-name overloads (hence `@stringFormat`, not a third `@format`). Consult it every time. See [ADR-0037](spec/decisions/ADR-0037-metamodel-vocabulary-expansion-decision-framework.md). - **Pattern-derivable from metadata = codegen, never hand-code.** This is the metaobjects raison d'être. If you find yourself proposing that users hand-write something the metadata fully describes (FK references, basic CRUD, validator chains, type-safe finders, relations() blocks), stop. Codegen it. The only exception is what metadata genuinely cannot express (custom SQL views, regex patterns from outside metadata, business logic). When in doubt, generate. diff --git a/spec/decisions/ADR-0036-metamodel-vocabulary-finalization-for-1.0.md b/spec/decisions/ADR-0036-metamodel-vocabulary-finalization-for-1.0.md index f8e89c858..907ecdf6b 100644 --- a/spec/decisions/ADR-0036-metamodel-vocabulary-finalization-for-1.0.md +++ b/spec/decisions/ADR-0036-metamodel-vocabulary-finalization-for-1.0.md @@ -33,9 +33,15 @@ The throughline (confirmed by a cross-framework study of 11 ORMs/IDLs): **base t Rationale: aware-by-default is the unanimous best-practice (Postgres "Don't Do This", Ecto, Django 5.0). `timetz` is discouraged and `date` is inherently naive, so the distinction applies only to `timestamp`. The attribute is the right shape (not a subtype) because timezone-awareness is an orthogonal modifier of one base type — the same shape as `@maxLength` on a string — not a different *kind* of value the way `field.uuid` is (ADR-0037, step 2). _Earlier drafts proposed a `field.localDateTime` subtype; that was corrected to the attribute under the ADR-0037 framework — the per-port native-binding conditional already exists, and a subtype would over-weight a rare modifier into a sibling type._ -### 2. Semantic string `@format` — a closed-set attribute on `field.string` +### 2. Semantic string formats — split by behavior (per ADR-0037) -Add `@format` with the cross-framework intersection value set: **`email | url | uuid | hostname | ipv4 | ipv6`**. The canonical matcher for each format lives in each port (conformance-gated) — *not* author-supplied `validator.regex`, because cross-language regex engines diverge (lookahead, Unicode classes) and would break byte-identity. Modeled as an attribute on `field.string` (the JSON Schema `format` model), not as new subtypes: format is a string-shape constraint, not a distinct storage type. Codegens idiomatically per port (Zod `z.email()`, class-validator `@IsEmail`, Jakarta `@Email`, PG `CHECK`/`citext`). +The "string formats" set does not survive the [ADR-0037](ADR-0037-metamodel-vocabulary-expansion-decision-framework.md) behavioral test (own native type / behavior → subtype; plain validated string → attribute), so it splits three ways: + +- **`field.uri` subtype** — `url`/`uri` have a native type (`URI`/`Uri`/`urllib`) and behavior (scheme/authority/path, absolute-vs-relative), so they are a subtype, with **`@kind` = `url` | `urn`** distinguishing the structural variants (a URL locates, a URN names). Native binding + parse behavior per port. +- **`field.inet` subtype** — `ipv4`/`ipv6` have a native type (`InetAddress`/`IPAddress`/`ipaddress`) and a Postgres-native `inet`/`cidr` column, so they are a subtype, with **`@kind` = `ipv4` | `ipv6`**. +- **`@stringFormat` attribute** on `field.string` — only the genuinely behavior-less, native-type-less validated strings: **`email | hostname`** (additive — more can be added later). The canonical matcher per format lives in each port (conformance-gated), *not* author `validator.regex` (cross-language regex engines diverge and break byte-identity). Named `@stringFormat` (not `@format`) to avoid colliding with the existing `template.* @format` (output format) — ADR-0037's no-overload corollary. Codegens idiomatically (Zod `z.email()`, Jakarta `@Email`, PG `CHECK`/`citext`). + +`uuid` is **not** in this set — it is already the `field.uuid` subtype (native UUID type), the original instance of this same rule. ### 3. Multi-edge-same-pair navigation → `@relationName`, fail-closed diff --git a/spec/decisions/ADR-0037-metamodel-vocabulary-expansion-decision-framework.md b/spec/decisions/ADR-0037-metamodel-vocabulary-expansion-decision-framework.md index b1a70962f..dd14bc18d 100644 --- a/spec/decisions/ADR-0037-metamodel-vocabulary-expansion-decision-framework.md +++ b/spec/decisions/ADR-0037-metamodel-vocabulary-expansion-decision-framework.md @@ -2,7 +2,7 @@ ## Status -**Accepted** (2026-06-30). +**Accepted** (2026-06-30). Revised same day to make the subtype test **behavioral** (own native type **or** behavior **or** attributes — not merely "distinct native type") and to charter **`@kind`** as the one structural-variant-within-a-subtype axis. This split the "string formats" set: `url`/`uri` and `ip` are subtypes (they have native types + behavior), only `email`/`hostname` are `@stringFormat` validated strings. ## Context @@ -12,7 +12,7 @@ This ADR consolidates them into **one ordered decision procedure**. It is the go ## Decision — the ordered test -To express a new concept **X**, apply these in order. **The first that matches decides.** +**The guiding question is always semantic behavior, not surface storage.** Don't ask "is X a string / a number / a date?" — ask "**what does X *do*?** Does it need its own behavior, attributes, or native type (then it's a *thing* — a subtype)? Is it a structural variant of an existing thing (a *kind*)? Or does it just modify, validate, or configure an existing type (an *attribute*)?" Shape follows behavior. The steps below operationalize that question; apply them in order — **the first that matches decides.** ### 0. Derivable from existing metadata? → derive it; add NOTHING. (ADR-0023) If X can be computed from the existing subtype + attributes (`isArray`, `@maxLength`, …) + structure (`identity.reference`, relationships) + naming, **generate it in codegen — do not add vocabulary.** Adding an attr/subtype for something the metadata already implies is the cardinal error. @@ -23,49 +23,64 @@ If X can be computed from the existing subtype + attributes (`isArray`, `@maxLen - **No — it's a pure DB-storage detail, logical type and native binding unchanged** → it is the narrow physical passthrough (`@dbColumnType`), used sparingly for genuine DB-specific needs the metamodel doesn't model (the jsonb open-bag; exotic Postgres types like `tsvector`). NOT first-class vocabulary. - **Yes — it changes the native type or the meaning** → it is logical; go to step 2. -### 2. Logical: a different KIND of value, or the SAME kind with a modifier? -- **Different kind of value → SUBTYPE.** A distinct semantic concept, typically with its own native type and/or column type, not reducible to "an existing type plus a property." - - Test: it reads as *"a different sort of thing,"* and binds to a native type that is **not** the base type's. - - `field.uuid` (native `UUID`/`Guid`/`uuid.UUID` + `uuid` column), `field.currency` (money, integer minor units), `field.enum` (closed symbol set), `field.decimal` (exact, distinct from float), `field.bytes` (binary). -- **Same kind of value, orthogonal modifier → ATTRIBUTE.** Still fundamentally the base type, just constrained or configured. - - Test: it reads as *"a `` that is also / happens to be X."* - - `@maxLength` (a string, bounded), `@precision`/`@scale` (a decimal, sized), `@format` (a string, shaped), `@localTime` (a timestamp, zone-flagged), `@currency`/`@locale` (a currency, configured). +### 2. Is X its own *thing* (a subtype), a *variant* of a thing (`@kind`), or a *modifier* of one (an attribute)? -### 3. Inside "attribute": shape the value-space. -- **Closed enum** → register with `allowedValues` (cross-port byte-gated, ADR-0036 Wave 1). -- **Boolean modifier** → default **false/absent = the common case**; the flag marks the **exception** (`@localTime: true` = the rare naive timestamp). Never a default-true opt-out. -- **Open / format-validated** (ISO 4217, BCP 47) → no `allowedValues`; validated by format. +**2a. Its own thing → SUBTYPE.** X warrants a subtype when it has **any** of: a **distinct native type** in the target languages, its **own behavior/logic**, or its **own attributes** worth carrying. The subtype is the metamodel's *extension point* — the only shape that owns custom codegen, custom validation, and custom child attributes. The test is **behavioral, not storage-based**: a value that happens to serialize as a string is still a subtype if the *concept* has a native type or behavior. + - `field.uuid` (native `UUID`/`Guid`), `field.currency` (money, minor-unit arithmetic), `field.decimal` (exact), `field.bytes` (binary), `field.uri` (native `URI`/`Uri`; parses scheme/authority/path), `field.inet` (native IP type; version/CIDR; PG `inet`). + - Litmus: *"would I want to attach behavior or extra attributes to this later?"* If plausibly yes → subtype. (A `field.uri` can grow a `@requireAbsolute` attr; an `@stringFormat: email` can't grow anything — it's a leaf constraint.) -## The format-vs-subtype rule (a worked instance of step 2 — because we got it wrong) +**2b. A structural variant *within* a subtype → `@kind`.** When a subtype has variants that change the *generated shape* but share its native type and behavior, discriminate them with the `@kind` attribute (its value-set is scoped per type/subtype and byte-gated). `@kind` is the metamodel's one **structural-variant axis** — reserved for exactly this, never a catch-all. + - `source.rdb @kind` = table/view/materializedView/storedProc/tableFunction; `template.output @kind` = document/email; (planned) `field.uri @kind` = url/urn (a URL locates, a URN names — both URIs); `field.inet @kind` = ipv4/ipv6. + - A variant qualifies for `@kind` only if it lives **inside** a subtype that already earned its place by 2a. `@kind` on a plain `field.string` is wrong — a plain string isn't a behavioral subtype, so there's nothing for the kinds to be *kinds of*. -A semantic **string format** (email, url, hostname, ipv4, ipv6) is the **same kind** of value — a string, `varchar` storage, native type `string` in every port — so it is a modifier → **attribute** (`@format`), never a subtype. +**2c. Otherwise X modifies/constrains/configures an existing type → ATTRIBUTE.** X is not its own concept — it adjusts one. Pick the attribute shape by what X is: + - **Boolean exception-flag** (the common case is *absent*) → boolean attr; the flag marks the exception. `@localTime`, `@unique`, `@required`. Never a default-true opt-out. + - **Closed set of choices** → enum attr with `allowedValues` (cross-port byte-gated, ADR-0036 Wave 1). + - **Validation constraint** that narrows the value without changing its type/structure → a validation/format attr. `@stringFormat` (email/hostname), `@maxLength`. The thing being validated stays a plain ``; there is no behavior to own (or it would be 2a). + - **Configuration value** (sizing, locale, precision) → a typed attr. `@precision`/`@scale`, `@currency`/`@locale`. Open/format-validated configs (ISO 4217, BCP 47) carry no `allowedValues`. -**Do not be misled by JSON-world tools** (JSON Schema, Zod, class-validator) that call `uuid` and `date-time` "formats." They do so only because JavaScript/JSON has no native UUID or instant type — to them everything is a string. MetaObjects binds metadata→native types across five languages (ADR-0001), so the operative test is: +## The format-vs-subtype rule (a worked instance of step 2 — because we got it wrong, twice) -> **Does X have a distinct native type across the ports?** If yes → SUBTYPE. If no (it's a `string` everywhere, just validated) → attribute (`@format`). +The set of "string formats" the JSON-world lumps together (uuid, url, email, hostname, ipv4, ipv6) **does not survive the behavioral test — it splits.** Run each through 2a ("own native type or behavior?"): -`uuid` → distinct native type (`UUID`/`Guid`) → **`field.uuid` subtype**. `email` → `string` everywhere → **`@format` attribute**. The rule in one line: **format = string-shape validation with the native type unchanged; subtype = a distinct native type.** +| "format" | native type in the ports? | own behavior? | → | +|---|---|---|---| +| uuid | `UUID`/`Guid`/`uuid.UUID` | — | **subtype** `field.uuid` | +| url / uri | `URI`/`Uri`/`urllib` | scheme/authority/path, absolute-vs-relative | **subtype** `field.uri` (+ `@kind` url/urn) | +| ipv4 / ipv6 | `InetAddress`/`IPAddress`/`ipaddress` (+ PG `inet`) | version, subnet/CIDR | **subtype** `field.inet` (+ `@kind` ipv4/ipv6) | +| email | none (string) | none | **attribute** `@stringFormat: email` | +| hostname | none | none | **attribute** `@stringFormat: hostname` | + +**Do not be misled by JSON-world tools** (JSON Schema, Zod, class-validator) that call all of these "formats." They do so only because JavaScript/JSON has *no native types* for any of them — to them everything is a string. MetaObjects binds metadata→native types across five languages (ADR-0001), so the question is behavioral: + +> **Does the concept have a native type or its own behavior?** Yes → **subtype** (and structural variants of it → `@kind`). No → it's a plain validated string → **attribute** (`@stringFormat`). + +The one-line rule: **`@stringFormat` is for a plain string that just needs validating; a subtype is for a concept with a native type or behavior of its own; `@kind` distinguishes variants *inside* such a subtype.** Email is a validated string. A URL is a thing. ## Consistency corollaries -- **Base type + semantic tag beats type proliferation.** Never mint a sibling type for a property — there is no `field.shortString`/`field.longString`; that is `@maxLength`. Collapsing distinct native types into one (the OpenAPI-Generator trap) is the opposite failure — avoid both. -- **Orthogonal modifiers live on the base type**, not a new subtype (`@localTime` on `field.timestamp`, not `field.localDateTime`). -- **Same concept → same attr name; never same-name-different-meaning.** When an existing attr name means something else on another type (the `@format` on `template.*` = output format), give the new one a distinct name rather than overload it. -- **Every new first-class element requires** a registered provider + a `registry-conformance` fixture (ADR-0023 strict provenance), and — for closed enums — `allowedValues` in the gate (ADR-0036). +- **Base type + semantic tag beats type proliferation.** Never mint a sibling type for a mere *property* — there is no `field.shortString`/`field.longString`; that is `@maxLength`. Collapsing distinct native types/behaviors into one (the OpenAPI-Generator trap) is the opposite failure — avoid both. +- **`@kind` is the one structural-variant axis — keep it chartered.** Use `@kind` only for variants *within* a subtype that earned its place by 2a (source kind, template kind, uri url/urn). Do **not** let `@kind` become a catch-all discriminator: it must never absorb a distinct-native-type concept (that's a subtype), a boolean flag (`@localTime`), or a validation constraint (`@stringFormat`). A `@kind` whose value-set is "anything" tells a reader nothing — the value of a name is self-documentation, which a chartered `@kind` keeps and a catch-all loses. +- **Self-documentation over economy.** Prefer a specific, named attribute (`@localTime`, `@stringFormat`, `@unique`) over folding several concerns into one generic attr. A name should tell you what it does without a per-type lookup table. The *primary* universal discriminator is already `type.subType`; don't invent a second one. +- **Same concept → same attr name; never same-name-different-meaning.** When an existing attr name means something else on another type (`@format` on `template.*` = output/serialization format), give the new one a distinct name (`@stringFormat`) rather than overload it. +- **Every new first-class element requires** a registered provider + a `registry-conformance` fixture (ADR-0023 strict provenance), and — for closed enums (including every `@kind` value-set) — `allowedValues` in the gate (ADR-0036). ## Worked examples (the decisions this framework reproduces) -| Concept | 0: derivable? | 1: physical? | 2: kind vs modifier | Result | +| Concept | 0: derivable? | 1: physical? | 2: native type / behavior? | Result | |---|---|---|---|---| | array of string | **yes** (`isArray`) | — | — | derive `text[]` — no vocabulary | | `varchar(n)` length | **yes** (`@maxLength`) | — | — | derive | | jsonb open bag | no | **physical** (string native) | — | `@dbColumnType: jsonb` | -| UUID | no | logical (UUID native) | different kind | **subtype** `field.uuid` | -| money | no | logical | different kind | **subtype** `field.currency` | -| binary | no | logical (bytes native) | different kind | **subtype** `field.bytes` | -| email / url | no | logical (validation) | same kind (string) + modifier | **attribute** `@format` | -| naive timestamp | no | logical (LocalDateTime native) | same kind (date+time) + modifier | **attribute** `@localTime` | -| decimal precision | no | logical | same kind (decimal) + modifier | **attribute** `@precision`/`@scale` | +| UUID | no | logical | native `UUID`/`Guid` | **subtype** `field.uuid` | +| money | no | logical | minor-unit behavior | **subtype** `field.currency` | +| binary | no | logical | native `bytes` | **subtype** `field.bytes` | +| URL / URI | no | logical | native `URI`/`Uri` + parse behavior | **subtype** `field.uri` (+ `@kind` url/urn) | +| IP address | no | logical | native IP type + CIDR | **subtype** `field.inet` (+ `@kind` ipv4/ipv6) | +| DB-object kind (table/view) | no | — | structural variant *within* `source.rdb` | **`@kind`** on source | +| email / hostname | no | logical | none (plain validated string) | **attribute** `@stringFormat` | +| naive timestamp | no | logical | boolean exception-flag | **attribute** `@localTime` | +| decimal precision | no | logical | config of a decimal | **attribute** `@precision`/`@scale` | ## Consequences From db8486d5ca48d78ebf99d445f8ac300fdd0f083a Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 30 Jun 2026 08:50:10 -0400 Subject: [PATCH 2/2] docs(agent-context): generalize the metadata-shape decision procedure (ADR-0037) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the field-specific "choosing the right field shape" guidance with a general, mechanical decision procedure an LLM can re-derive for ANY concept — not a lookup table of specific answers. The guiding question is semantic behavior, not surface storage ("what does X do?", never "is X a string/number/date?"). The ordered routing from ADR-0037: (0) derivable -> derive, add nothing; (1) physical-only -> @dbColumnType escape hatch; (2a) own native type/behavior/attributes -> subtype; (2b) structural variant within a subtype -> @kind; (2c) modifies/validates/configures -> attribute. Adds the self-documentation-over-economy and no-same-name-overload corollaries, a compact decision table, and an ADR-0037 authority pointer. Examples use only currently-registered vocab (field.uuid/currency/decimal, source.rdb @kind, template.output @kind, @localTime, @maxLength/@precision) so the capability-grounding + drift gates stay green; the general rule for native- type-vs-validated-string is stated abstractly without naming un-built Wave 3 vocab. Codegen skill cross-reference updated to point at the renamed section. Conformance fixtures regenerated for all four stacks. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n --- .../skills/metaobjects-authoring/SKILL.md | 92 ++++++++++++++----- .../skills/metaobjects-codegen/SKILL.md | 12 +-- .../skills/metaobjects-authoring/SKILL.md | 92 ++++++++++++++----- .../skills/metaobjects-codegen/SKILL.md | 12 +-- .../skills/metaobjects-authoring/SKILL.md | 92 ++++++++++++++----- .../skills/metaobjects-codegen/SKILL.md | 12 +-- .../skills/metaobjects-authoring/SKILL.md | 92 ++++++++++++++----- .../skills/metaobjects-codegen/SKILL.md | 12 +-- .../skills/metaobjects-authoring/SKILL.md | 92 ++++++++++++++----- .../skills/metaobjects-codegen/SKILL.md | 12 +-- 10 files changed, 385 insertions(+), 135 deletions(-) diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index 9f317d349..4cc17cff4 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -161,22 +161,66 @@ name package extends abstract overlay isArray children value Common field attributes: `@required`, `@maxLength`, `@column` (physical column name), `@default`, `@filterable`, `@sortable`. -### Choosing the right field shape - -When a need doesn't obviously map to a subtype, run this ordered test (ADR-0037) — -**the first that matches decides:** - -1. **Derivable from what you already have?** → derive it, declare NOTHING new. An - array is `isArray: true` (never an array column type); a bounded string is - `@maxLength`; FK columns come from `identity.reference`. Don't add vocabulary for - something the metadata already implies. -2. **Pure physical-DB detail, native type unchanged?** → the narrow `@dbColumnType` - escape hatch (e.g. an open JSON bag). Used sparingly; not a logical type. -3. **Logical concept** — a *different kind* of value (its own native type, e.g. - UUID, money) → a **subtype**; the *same kind* with a modifier (still a string/ - decimal, just constrained) → an **attribute** (`@maxLength`, `@precision`). - -Canonical form for common needs — reach for these before inventing anything: +### Choosing the right shape — the general decision procedure (ADR-0037) + +This procedure decides the shape of **any** concept entering the metamodel — a +field need today, or new vocabulary you register as a custom provider. It is not a +lookup table of specific answers; it is the routing an LLM re-derives on its own +for a concept it has never seen. + +**Ask what the concept *does*, never how it stores.** The guiding question is +**semantic behavior, not surface storage**: never ask *"is X a string / a number / +a date?"* — ask *"what does X **do**? Does it have its own native type, behavior, +or attributes (a **thing** → subtype)? Is it a structural variant of an existing +thing (a **kind**)? Or does it just modify, validate, or configure an existing type +(an **attribute**)?"* Shape follows behavior. Don't be misled by tools (JSON +Schema, Zod) that call everything a "string format" — they only do so because +JS/JSON has no native types; MetaObjects binds metadata→native types across five +languages, so the call is behavioral. + +Run the steps **in order; the first that matches decides:** + +| # | Test | If yes → | Examples (existing vocab) | +|---|---|---|---| +| 0 | **Derivable** from the existing subtype + attrs (`isArray`, `@maxLength`) + structure (`identity.reference`, relationships) + naming? | **derive it in codegen — add NOTHING** | `text[]` ← `field.string` + `isArray`; `varchar(n)` ← `@maxLength`; FK columns ← `identity.reference` | +| 1 | **Physical-only** — pure DB-storage detail, native type *and* meaning unchanged? | narrow **`@dbColumnType`** escape hatch (sparingly; not a logical type) | open JSON bag → `field.string` + `@dbColumnType: jsonb` | +| 2a | Its **own thing** — has its own native type, **or** its own behavior, **or** its own attributes? | **SUBTYPE** (the extension point — owns custom codegen, validation, child attrs) | `field.uuid` (native UUID), `field.currency` (minor-unit money behavior), `field.decimal` (exact) | +| 2b | A **structural variant within** a subtype that already earned 2a — same native type/behavior, different generated *shape*? | **`@kind`** (the one chartered structural-variant axis) | `source.rdb @kind`: table/view/materializedView/storedProc/tableFunction; `template.output @kind`: document/email | +| 2c | Otherwise it **modifies / validates / configures** an existing type | **ATTRIBUTE** (boolean flag · closed enum · validation · config) | `@localTime` (boolean exception-flag); `@maxLength`/`@precision`/`@scale` (config) | + +**Reading step 2 (the load-bearing split):** +- **2a — subtype** is the metamodel's *extension point*: the only shape that owns + custom logic. Litmus: *"would I plausibly want to attach behavior or extra + attributes to this later?"* If yes → subtype. A value that merely *serializes* as + a string is still a subtype if the **concept** has a native type or behavior of + its own. (General rule, stated abstractly so it survives un-built vocab: *a + concept with a native type or its own behavior becomes a subtype; a plain string + that just needs validating becomes a validation attribute.*) +- **2b — `@kind`** is reserved for variants *inside* a subtype that earned its place + by 2a. `@kind` on a plain `field.string` is wrong: a plain string isn't a + behavioral subtype, so there's nothing for the kinds to be *kinds of*. Never let + `@kind` become a catch-all discriminator. +- **2c — attribute** shape follows what it is: a **boolean exception-flag** whose + common case is *absent* (`@localTime` — never a default-true opt-out); a **closed + set** → enum attr with `allowedValues`; a **validation constraint** that narrows a + value without changing its type (the thing stays a plain ``, there's no + behavior to own — else it would be 2a); a **config value** (sizing, precision, + locale) → a typed attr (`@precision`/`@scale`). + +**Two corollaries that break ties:** +- **Self-documentation over economy.** Prefer a specific named attribute + (`@localTime`, `@unique`) over folding several concerns into one generic attr. A + name should tell you what it does without a per-type lookup. The *primary* + universal discriminator is already `type.subType` — don't invent a second one. +- **Same concept → same attr name; never same-name / different meaning.** If an attr + name already means something else on another type, give the new one a distinct + name rather than overload it. + +This procedure is authority-backed: **ADR-0037** is the source of truth, sequencing +ADR-0013 (physical vs logical), ADR-0023 (derive, don't invent), and ADR-0001 +(build-time native binding). + +Canonical form for common field needs — reach for these before inventing anything: | Need | Author it as | Note | |---|---|---| @@ -205,11 +249,17 @@ lives in `field.timestamp` (instant by default) + the `@localTime` naive opt-out -**Extending the metamodel (custom providers):** the same ordered test governs new -vocabulary you register. A would-be subtype that differs from an existing one only -by a property is an **attribute**, not a subtype; a string-shape validation -(email/url) is an attribute, not a subtype (its native type is still `string`). -Apply ADR-0037 before registering anything. +**Extending the metamodel (custom providers):** the same ordered procedure above +governs new vocabulary you register — apply it mechanically before registering +anything. A would-be subtype that differs from an existing one only by a *property* +is an **attribute**, not a subtype (a "short string" isn't a new field subtype — +that is `@maxLength`); a plain string that merely needs validating is a **validation +attribute**, not a subtype (its native type is still `string`, and there's no +behavior to own); a concept with its own native type or behavior is a **subtype**, +and structural variants *within* such a subtype are `@kind`. Every new first-class +element also requires a registered provider + a `registry-conformance` fixture +(ADR-0023 strict provenance), and closed enums (including any `@kind` value-set) +carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority. ### Currency diff --git a/agent-context/skills/metaobjects-codegen/SKILL.md b/agent-context/skills/metaobjects-codegen/SKILL.md index b44b679ed..e333b2077 100644 --- a/agent-context/skills/metaobjects-codegen/SKILL.md +++ b/agent-context/skills/metaobjects-codegen/SKILL.md @@ -159,12 +159,12 @@ Postgres, `field.enum` → `varchar` + `CHECK`, etc.). Codegen only ever maps the **shapes you authored** — so author them right. If you find the generator emitting the wrong column type, the fix is the field shape, not a -template hack. See "Choosing the right field shape" in the **`metaobjects-authoring`** -skill for the ordered subtype-vs-attribute-vs-`@dbColumnType` test (ADR-0037) — e.g. -arrays are `isArray: true` (never an array column type) and a native UUID is -`field.uuid` (not a string + `@dbColumnType`). When you register custom vocabulary -for a custom generator, the same ADR-0037 procedure decides whether it's a subtype or -an attribute. +template hack. See "Choosing the right shape — the general decision procedure" in the +**`metaobjects-authoring`** skill for the ordered derive→`@dbColumnType`→subtype/ +`@kind`/attribute routing (ADR-0037) — e.g. arrays are `isArray: true` (never an +array column type) and a native UUID is `field.uuid` (not a string + `@dbColumnType`). +When you register custom vocabulary for a custom generator, the same ADR-0037 +procedure decides whether it's a subtype, a `@kind` variant, or an attribute. ## Per-target output diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index 9f317d349..4cc17cff4 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -161,22 +161,66 @@ name package extends abstract overlay isArray children value Common field attributes: `@required`, `@maxLength`, `@column` (physical column name), `@default`, `@filterable`, `@sortable`. -### Choosing the right field shape - -When a need doesn't obviously map to a subtype, run this ordered test (ADR-0037) — -**the first that matches decides:** - -1. **Derivable from what you already have?** → derive it, declare NOTHING new. An - array is `isArray: true` (never an array column type); a bounded string is - `@maxLength`; FK columns come from `identity.reference`. Don't add vocabulary for - something the metadata already implies. -2. **Pure physical-DB detail, native type unchanged?** → the narrow `@dbColumnType` - escape hatch (e.g. an open JSON bag). Used sparingly; not a logical type. -3. **Logical concept** — a *different kind* of value (its own native type, e.g. - UUID, money) → a **subtype**; the *same kind* with a modifier (still a string/ - decimal, just constrained) → an **attribute** (`@maxLength`, `@precision`). - -Canonical form for common needs — reach for these before inventing anything: +### Choosing the right shape — the general decision procedure (ADR-0037) + +This procedure decides the shape of **any** concept entering the metamodel — a +field need today, or new vocabulary you register as a custom provider. It is not a +lookup table of specific answers; it is the routing an LLM re-derives on its own +for a concept it has never seen. + +**Ask what the concept *does*, never how it stores.** The guiding question is +**semantic behavior, not surface storage**: never ask *"is X a string / a number / +a date?"* — ask *"what does X **do**? Does it have its own native type, behavior, +or attributes (a **thing** → subtype)? Is it a structural variant of an existing +thing (a **kind**)? Or does it just modify, validate, or configure an existing type +(an **attribute**)?"* Shape follows behavior. Don't be misled by tools (JSON +Schema, Zod) that call everything a "string format" — they only do so because +JS/JSON has no native types; MetaObjects binds metadata→native types across five +languages, so the call is behavioral. + +Run the steps **in order; the first that matches decides:** + +| # | Test | If yes → | Examples (existing vocab) | +|---|---|---|---| +| 0 | **Derivable** from the existing subtype + attrs (`isArray`, `@maxLength`) + structure (`identity.reference`, relationships) + naming? | **derive it in codegen — add NOTHING** | `text[]` ← `field.string` + `isArray`; `varchar(n)` ← `@maxLength`; FK columns ← `identity.reference` | +| 1 | **Physical-only** — pure DB-storage detail, native type *and* meaning unchanged? | narrow **`@dbColumnType`** escape hatch (sparingly; not a logical type) | open JSON bag → `field.string` + `@dbColumnType: jsonb` | +| 2a | Its **own thing** — has its own native type, **or** its own behavior, **or** its own attributes? | **SUBTYPE** (the extension point — owns custom codegen, validation, child attrs) | `field.uuid` (native UUID), `field.currency` (minor-unit money behavior), `field.decimal` (exact) | +| 2b | A **structural variant within** a subtype that already earned 2a — same native type/behavior, different generated *shape*? | **`@kind`** (the one chartered structural-variant axis) | `source.rdb @kind`: table/view/materializedView/storedProc/tableFunction; `template.output @kind`: document/email | +| 2c | Otherwise it **modifies / validates / configures** an existing type | **ATTRIBUTE** (boolean flag · closed enum · validation · config) | `@localTime` (boolean exception-flag); `@maxLength`/`@precision`/`@scale` (config) | + +**Reading step 2 (the load-bearing split):** +- **2a — subtype** is the metamodel's *extension point*: the only shape that owns + custom logic. Litmus: *"would I plausibly want to attach behavior or extra + attributes to this later?"* If yes → subtype. A value that merely *serializes* as + a string is still a subtype if the **concept** has a native type or behavior of + its own. (General rule, stated abstractly so it survives un-built vocab: *a + concept with a native type or its own behavior becomes a subtype; a plain string + that just needs validating becomes a validation attribute.*) +- **2b — `@kind`** is reserved for variants *inside* a subtype that earned its place + by 2a. `@kind` on a plain `field.string` is wrong: a plain string isn't a + behavioral subtype, so there's nothing for the kinds to be *kinds of*. Never let + `@kind` become a catch-all discriminator. +- **2c — attribute** shape follows what it is: a **boolean exception-flag** whose + common case is *absent* (`@localTime` — never a default-true opt-out); a **closed + set** → enum attr with `allowedValues`; a **validation constraint** that narrows a + value without changing its type (the thing stays a plain ``, there's no + behavior to own — else it would be 2a); a **config value** (sizing, precision, + locale) → a typed attr (`@precision`/`@scale`). + +**Two corollaries that break ties:** +- **Self-documentation over economy.** Prefer a specific named attribute + (`@localTime`, `@unique`) over folding several concerns into one generic attr. A + name should tell you what it does without a per-type lookup. The *primary* + universal discriminator is already `type.subType` — don't invent a second one. +- **Same concept → same attr name; never same-name / different meaning.** If an attr + name already means something else on another type, give the new one a distinct + name rather than overload it. + +This procedure is authority-backed: **ADR-0037** is the source of truth, sequencing +ADR-0013 (physical vs logical), ADR-0023 (derive, don't invent), and ADR-0001 +(build-time native binding). + +Canonical form for common field needs — reach for these before inventing anything: | Need | Author it as | Note | |---|---|---| @@ -205,11 +249,17 @@ lives in `field.timestamp` (instant by default) + the `@localTime` naive opt-out -**Extending the metamodel (custom providers):** the same ordered test governs new -vocabulary you register. A would-be subtype that differs from an existing one only -by a property is an **attribute**, not a subtype; a string-shape validation -(email/url) is an attribute, not a subtype (its native type is still `string`). -Apply ADR-0037 before registering anything. +**Extending the metamodel (custom providers):** the same ordered procedure above +governs new vocabulary you register — apply it mechanically before registering +anything. A would-be subtype that differs from an existing one only by a *property* +is an **attribute**, not a subtype (a "short string" isn't a new field subtype — +that is `@maxLength`); a plain string that merely needs validating is a **validation +attribute**, not a subtype (its native type is still `string`, and there's no +behavior to own); a concept with its own native type or behavior is a **subtype**, +and structural variants *within* such a subtype are `@kind`. Every new first-class +element also requires a registered provider + a `registry-conformance` fixture +(ADR-0023 strict provenance), and closed enums (including any `@kind` value-set) +carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority. ### Currency diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index b44b679ed..e333b2077 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -159,12 +159,12 @@ Postgres, `field.enum` → `varchar` + `CHECK`, etc.). Codegen only ever maps the **shapes you authored** — so author them right. If you find the generator emitting the wrong column type, the fix is the field shape, not a -template hack. See "Choosing the right field shape" in the **`metaobjects-authoring`** -skill for the ordered subtype-vs-attribute-vs-`@dbColumnType` test (ADR-0037) — e.g. -arrays are `isArray: true` (never an array column type) and a native UUID is -`field.uuid` (not a string + `@dbColumnType`). When you register custom vocabulary -for a custom generator, the same ADR-0037 procedure decides whether it's a subtype or -an attribute. +template hack. See "Choosing the right shape — the general decision procedure" in the +**`metaobjects-authoring`** skill for the ordered derive→`@dbColumnType`→subtype/ +`@kind`/attribute routing (ADR-0037) — e.g. arrays are `isArray: true` (never an +array column type) and a native UUID is `field.uuid` (not a string + `@dbColumnType`). +When you register custom vocabulary for a custom generator, the same ADR-0037 +procedure decides whether it's a subtype, a `@kind` variant, or an attribute. ## Per-target output diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md index 9f317d349..4cc17cff4 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -161,22 +161,66 @@ name package extends abstract overlay isArray children value Common field attributes: `@required`, `@maxLength`, `@column` (physical column name), `@default`, `@filterable`, `@sortable`. -### Choosing the right field shape - -When a need doesn't obviously map to a subtype, run this ordered test (ADR-0037) — -**the first that matches decides:** - -1. **Derivable from what you already have?** → derive it, declare NOTHING new. An - array is `isArray: true` (never an array column type); a bounded string is - `@maxLength`; FK columns come from `identity.reference`. Don't add vocabulary for - something the metadata already implies. -2. **Pure physical-DB detail, native type unchanged?** → the narrow `@dbColumnType` - escape hatch (e.g. an open JSON bag). Used sparingly; not a logical type. -3. **Logical concept** — a *different kind* of value (its own native type, e.g. - UUID, money) → a **subtype**; the *same kind* with a modifier (still a string/ - decimal, just constrained) → an **attribute** (`@maxLength`, `@precision`). - -Canonical form for common needs — reach for these before inventing anything: +### Choosing the right shape — the general decision procedure (ADR-0037) + +This procedure decides the shape of **any** concept entering the metamodel — a +field need today, or new vocabulary you register as a custom provider. It is not a +lookup table of specific answers; it is the routing an LLM re-derives on its own +for a concept it has never seen. + +**Ask what the concept *does*, never how it stores.** The guiding question is +**semantic behavior, not surface storage**: never ask *"is X a string / a number / +a date?"* — ask *"what does X **do**? Does it have its own native type, behavior, +or attributes (a **thing** → subtype)? Is it a structural variant of an existing +thing (a **kind**)? Or does it just modify, validate, or configure an existing type +(an **attribute**)?"* Shape follows behavior. Don't be misled by tools (JSON +Schema, Zod) that call everything a "string format" — they only do so because +JS/JSON has no native types; MetaObjects binds metadata→native types across five +languages, so the call is behavioral. + +Run the steps **in order; the first that matches decides:** + +| # | Test | If yes → | Examples (existing vocab) | +|---|---|---|---| +| 0 | **Derivable** from the existing subtype + attrs (`isArray`, `@maxLength`) + structure (`identity.reference`, relationships) + naming? | **derive it in codegen — add NOTHING** | `text[]` ← `field.string` + `isArray`; `varchar(n)` ← `@maxLength`; FK columns ← `identity.reference` | +| 1 | **Physical-only** — pure DB-storage detail, native type *and* meaning unchanged? | narrow **`@dbColumnType`** escape hatch (sparingly; not a logical type) | open JSON bag → `field.string` + `@dbColumnType: jsonb` | +| 2a | Its **own thing** — has its own native type, **or** its own behavior, **or** its own attributes? | **SUBTYPE** (the extension point — owns custom codegen, validation, child attrs) | `field.uuid` (native UUID), `field.currency` (minor-unit money behavior), `field.decimal` (exact) | +| 2b | A **structural variant within** a subtype that already earned 2a — same native type/behavior, different generated *shape*? | **`@kind`** (the one chartered structural-variant axis) | `source.rdb @kind`: table/view/materializedView/storedProc/tableFunction; `template.output @kind`: document/email | +| 2c | Otherwise it **modifies / validates / configures** an existing type | **ATTRIBUTE** (boolean flag · closed enum · validation · config) | `@localTime` (boolean exception-flag); `@maxLength`/`@precision`/`@scale` (config) | + +**Reading step 2 (the load-bearing split):** +- **2a — subtype** is the metamodel's *extension point*: the only shape that owns + custom logic. Litmus: *"would I plausibly want to attach behavior or extra + attributes to this later?"* If yes → subtype. A value that merely *serializes* as + a string is still a subtype if the **concept** has a native type or behavior of + its own. (General rule, stated abstractly so it survives un-built vocab: *a + concept with a native type or its own behavior becomes a subtype; a plain string + that just needs validating becomes a validation attribute.*) +- **2b — `@kind`** is reserved for variants *inside* a subtype that earned its place + by 2a. `@kind` on a plain `field.string` is wrong: a plain string isn't a + behavioral subtype, so there's nothing for the kinds to be *kinds of*. Never let + `@kind` become a catch-all discriminator. +- **2c — attribute** shape follows what it is: a **boolean exception-flag** whose + common case is *absent* (`@localTime` — never a default-true opt-out); a **closed + set** → enum attr with `allowedValues`; a **validation constraint** that narrows a + value without changing its type (the thing stays a plain ``, there's no + behavior to own — else it would be 2a); a **config value** (sizing, precision, + locale) → a typed attr (`@precision`/`@scale`). + +**Two corollaries that break ties:** +- **Self-documentation over economy.** Prefer a specific named attribute + (`@localTime`, `@unique`) over folding several concerns into one generic attr. A + name should tell you what it does without a per-type lookup. The *primary* + universal discriminator is already `type.subType` — don't invent a second one. +- **Same concept → same attr name; never same-name / different meaning.** If an attr + name already means something else on another type, give the new one a distinct + name rather than overload it. + +This procedure is authority-backed: **ADR-0037** is the source of truth, sequencing +ADR-0013 (physical vs logical), ADR-0023 (derive, don't invent), and ADR-0001 +(build-time native binding). + +Canonical form for common field needs — reach for these before inventing anything: | Need | Author it as | Note | |---|---|---| @@ -205,11 +249,17 @@ lives in `field.timestamp` (instant by default) + the `@localTime` naive opt-out -**Extending the metamodel (custom providers):** the same ordered test governs new -vocabulary you register. A would-be subtype that differs from an existing one only -by a property is an **attribute**, not a subtype; a string-shape validation -(email/url) is an attribute, not a subtype (its native type is still `string`). -Apply ADR-0037 before registering anything. +**Extending the metamodel (custom providers):** the same ordered procedure above +governs new vocabulary you register — apply it mechanically before registering +anything. A would-be subtype that differs from an existing one only by a *property* +is an **attribute**, not a subtype (a "short string" isn't a new field subtype — +that is `@maxLength`); a plain string that merely needs validating is a **validation +attribute**, not a subtype (its native type is still `string`, and there's no +behavior to own); a concept with its own native type or behavior is a **subtype**, +and structural variants *within* such a subtype are `@kind`. Every new first-class +element also requires a registered provider + a `registry-conformance` fixture +(ADR-0023 strict provenance), and closed enums (including any `@kind` value-set) +carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority. ### Currency diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md index b44b679ed..e333b2077 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -159,12 +159,12 @@ Postgres, `field.enum` → `varchar` + `CHECK`, etc.). Codegen only ever maps the **shapes you authored** — so author them right. If you find the generator emitting the wrong column type, the fix is the field shape, not a -template hack. See "Choosing the right field shape" in the **`metaobjects-authoring`** -skill for the ordered subtype-vs-attribute-vs-`@dbColumnType` test (ADR-0037) — e.g. -arrays are `isArray: true` (never an array column type) and a native UUID is -`field.uuid` (not a string + `@dbColumnType`). When you register custom vocabulary -for a custom generator, the same ADR-0037 procedure decides whether it's a subtype or -an attribute. +template hack. See "Choosing the right shape — the general decision procedure" in the +**`metaobjects-authoring`** skill for the ordered derive→`@dbColumnType`→subtype/ +`@kind`/attribute routing (ADR-0037) — e.g. arrays are `isArray: true` (never an +array column type) and a native UUID is `field.uuid` (not a string + `@dbColumnType`). +When you register custom vocabulary for a custom generator, the same ADR-0037 +procedure decides whether it's a subtype, a `@kind` variant, or an attribute. ## Per-target output diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md index 9f317d349..4cc17cff4 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -161,22 +161,66 @@ name package extends abstract overlay isArray children value Common field attributes: `@required`, `@maxLength`, `@column` (physical column name), `@default`, `@filterable`, `@sortable`. -### Choosing the right field shape - -When a need doesn't obviously map to a subtype, run this ordered test (ADR-0037) — -**the first that matches decides:** - -1. **Derivable from what you already have?** → derive it, declare NOTHING new. An - array is `isArray: true` (never an array column type); a bounded string is - `@maxLength`; FK columns come from `identity.reference`. Don't add vocabulary for - something the metadata already implies. -2. **Pure physical-DB detail, native type unchanged?** → the narrow `@dbColumnType` - escape hatch (e.g. an open JSON bag). Used sparingly; not a logical type. -3. **Logical concept** — a *different kind* of value (its own native type, e.g. - UUID, money) → a **subtype**; the *same kind* with a modifier (still a string/ - decimal, just constrained) → an **attribute** (`@maxLength`, `@precision`). - -Canonical form for common needs — reach for these before inventing anything: +### Choosing the right shape — the general decision procedure (ADR-0037) + +This procedure decides the shape of **any** concept entering the metamodel — a +field need today, or new vocabulary you register as a custom provider. It is not a +lookup table of specific answers; it is the routing an LLM re-derives on its own +for a concept it has never seen. + +**Ask what the concept *does*, never how it stores.** The guiding question is +**semantic behavior, not surface storage**: never ask *"is X a string / a number / +a date?"* — ask *"what does X **do**? Does it have its own native type, behavior, +or attributes (a **thing** → subtype)? Is it a structural variant of an existing +thing (a **kind**)? Or does it just modify, validate, or configure an existing type +(an **attribute**)?"* Shape follows behavior. Don't be misled by tools (JSON +Schema, Zod) that call everything a "string format" — they only do so because +JS/JSON has no native types; MetaObjects binds metadata→native types across five +languages, so the call is behavioral. + +Run the steps **in order; the first that matches decides:** + +| # | Test | If yes → | Examples (existing vocab) | +|---|---|---|---| +| 0 | **Derivable** from the existing subtype + attrs (`isArray`, `@maxLength`) + structure (`identity.reference`, relationships) + naming? | **derive it in codegen — add NOTHING** | `text[]` ← `field.string` + `isArray`; `varchar(n)` ← `@maxLength`; FK columns ← `identity.reference` | +| 1 | **Physical-only** — pure DB-storage detail, native type *and* meaning unchanged? | narrow **`@dbColumnType`** escape hatch (sparingly; not a logical type) | open JSON bag → `field.string` + `@dbColumnType: jsonb` | +| 2a | Its **own thing** — has its own native type, **or** its own behavior, **or** its own attributes? | **SUBTYPE** (the extension point — owns custom codegen, validation, child attrs) | `field.uuid` (native UUID), `field.currency` (minor-unit money behavior), `field.decimal` (exact) | +| 2b | A **structural variant within** a subtype that already earned 2a — same native type/behavior, different generated *shape*? | **`@kind`** (the one chartered structural-variant axis) | `source.rdb @kind`: table/view/materializedView/storedProc/tableFunction; `template.output @kind`: document/email | +| 2c | Otherwise it **modifies / validates / configures** an existing type | **ATTRIBUTE** (boolean flag · closed enum · validation · config) | `@localTime` (boolean exception-flag); `@maxLength`/`@precision`/`@scale` (config) | + +**Reading step 2 (the load-bearing split):** +- **2a — subtype** is the metamodel's *extension point*: the only shape that owns + custom logic. Litmus: *"would I plausibly want to attach behavior or extra + attributes to this later?"* If yes → subtype. A value that merely *serializes* as + a string is still a subtype if the **concept** has a native type or behavior of + its own. (General rule, stated abstractly so it survives un-built vocab: *a + concept with a native type or its own behavior becomes a subtype; a plain string + that just needs validating becomes a validation attribute.*) +- **2b — `@kind`** is reserved for variants *inside* a subtype that earned its place + by 2a. `@kind` on a plain `field.string` is wrong: a plain string isn't a + behavioral subtype, so there's nothing for the kinds to be *kinds of*. Never let + `@kind` become a catch-all discriminator. +- **2c — attribute** shape follows what it is: a **boolean exception-flag** whose + common case is *absent* (`@localTime` — never a default-true opt-out); a **closed + set** → enum attr with `allowedValues`; a **validation constraint** that narrows a + value without changing its type (the thing stays a plain ``, there's no + behavior to own — else it would be 2a); a **config value** (sizing, precision, + locale) → a typed attr (`@precision`/`@scale`). + +**Two corollaries that break ties:** +- **Self-documentation over economy.** Prefer a specific named attribute + (`@localTime`, `@unique`) over folding several concerns into one generic attr. A + name should tell you what it does without a per-type lookup. The *primary* + universal discriminator is already `type.subType` — don't invent a second one. +- **Same concept → same attr name; never same-name / different meaning.** If an attr + name already means something else on another type, give the new one a distinct + name rather than overload it. + +This procedure is authority-backed: **ADR-0037** is the source of truth, sequencing +ADR-0013 (physical vs logical), ADR-0023 (derive, don't invent), and ADR-0001 +(build-time native binding). + +Canonical form for common field needs — reach for these before inventing anything: | Need | Author it as | Note | |---|---|---| @@ -205,11 +249,17 @@ lives in `field.timestamp` (instant by default) + the `@localTime` naive opt-out -**Extending the metamodel (custom providers):** the same ordered test governs new -vocabulary you register. A would-be subtype that differs from an existing one only -by a property is an **attribute**, not a subtype; a string-shape validation -(email/url) is an attribute, not a subtype (its native type is still `string`). -Apply ADR-0037 before registering anything. +**Extending the metamodel (custom providers):** the same ordered procedure above +governs new vocabulary you register — apply it mechanically before registering +anything. A would-be subtype that differs from an existing one only by a *property* +is an **attribute**, not a subtype (a "short string" isn't a new field subtype — +that is `@maxLength`); a plain string that merely needs validating is a **validation +attribute**, not a subtype (its native type is still `string`, and there's no +behavior to own); a concept with its own native type or behavior is a **subtype**, +and structural variants *within* such a subtype are `@kind`. Every new first-class +element also requires a registered provider + a `registry-conformance` fixture +(ADR-0023 strict provenance), and closed enums (including any `@kind` value-set) +carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority. ### Currency diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md index b44b679ed..e333b2077 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -159,12 +159,12 @@ Postgres, `field.enum` → `varchar` + `CHECK`, etc.). Codegen only ever maps the **shapes you authored** — so author them right. If you find the generator emitting the wrong column type, the fix is the field shape, not a -template hack. See "Choosing the right field shape" in the **`metaobjects-authoring`** -skill for the ordered subtype-vs-attribute-vs-`@dbColumnType` test (ADR-0037) — e.g. -arrays are `isArray: true` (never an array column type) and a native UUID is -`field.uuid` (not a string + `@dbColumnType`). When you register custom vocabulary -for a custom generator, the same ADR-0037 procedure decides whether it's a subtype or -an attribute. +template hack. See "Choosing the right shape — the general decision procedure" in the +**`metaobjects-authoring`** skill for the ordered derive→`@dbColumnType`→subtype/ +`@kind`/attribute routing (ADR-0037) — e.g. arrays are `isArray: true` (never an +array column type) and a native UUID is `field.uuid` (not a string + `@dbColumnType`). +When you register custom vocabulary for a custom generator, the same ADR-0037 +procedure decides whether it's a subtype, a `@kind` variant, or an attribute. ## Per-target output diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index 9f317d349..4cc17cff4 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -161,22 +161,66 @@ name package extends abstract overlay isArray children value Common field attributes: `@required`, `@maxLength`, `@column` (physical column name), `@default`, `@filterable`, `@sortable`. -### Choosing the right field shape - -When a need doesn't obviously map to a subtype, run this ordered test (ADR-0037) — -**the first that matches decides:** - -1. **Derivable from what you already have?** → derive it, declare NOTHING new. An - array is `isArray: true` (never an array column type); a bounded string is - `@maxLength`; FK columns come from `identity.reference`. Don't add vocabulary for - something the metadata already implies. -2. **Pure physical-DB detail, native type unchanged?** → the narrow `@dbColumnType` - escape hatch (e.g. an open JSON bag). Used sparingly; not a logical type. -3. **Logical concept** — a *different kind* of value (its own native type, e.g. - UUID, money) → a **subtype**; the *same kind* with a modifier (still a string/ - decimal, just constrained) → an **attribute** (`@maxLength`, `@precision`). - -Canonical form for common needs — reach for these before inventing anything: +### Choosing the right shape — the general decision procedure (ADR-0037) + +This procedure decides the shape of **any** concept entering the metamodel — a +field need today, or new vocabulary you register as a custom provider. It is not a +lookup table of specific answers; it is the routing an LLM re-derives on its own +for a concept it has never seen. + +**Ask what the concept *does*, never how it stores.** The guiding question is +**semantic behavior, not surface storage**: never ask *"is X a string / a number / +a date?"* — ask *"what does X **do**? Does it have its own native type, behavior, +or attributes (a **thing** → subtype)? Is it a structural variant of an existing +thing (a **kind**)? Or does it just modify, validate, or configure an existing type +(an **attribute**)?"* Shape follows behavior. Don't be misled by tools (JSON +Schema, Zod) that call everything a "string format" — they only do so because +JS/JSON has no native types; MetaObjects binds metadata→native types across five +languages, so the call is behavioral. + +Run the steps **in order; the first that matches decides:** + +| # | Test | If yes → | Examples (existing vocab) | +|---|---|---|---| +| 0 | **Derivable** from the existing subtype + attrs (`isArray`, `@maxLength`) + structure (`identity.reference`, relationships) + naming? | **derive it in codegen — add NOTHING** | `text[]` ← `field.string` + `isArray`; `varchar(n)` ← `@maxLength`; FK columns ← `identity.reference` | +| 1 | **Physical-only** — pure DB-storage detail, native type *and* meaning unchanged? | narrow **`@dbColumnType`** escape hatch (sparingly; not a logical type) | open JSON bag → `field.string` + `@dbColumnType: jsonb` | +| 2a | Its **own thing** — has its own native type, **or** its own behavior, **or** its own attributes? | **SUBTYPE** (the extension point — owns custom codegen, validation, child attrs) | `field.uuid` (native UUID), `field.currency` (minor-unit money behavior), `field.decimal` (exact) | +| 2b | A **structural variant within** a subtype that already earned 2a — same native type/behavior, different generated *shape*? | **`@kind`** (the one chartered structural-variant axis) | `source.rdb @kind`: table/view/materializedView/storedProc/tableFunction; `template.output @kind`: document/email | +| 2c | Otherwise it **modifies / validates / configures** an existing type | **ATTRIBUTE** (boolean flag · closed enum · validation · config) | `@localTime` (boolean exception-flag); `@maxLength`/`@precision`/`@scale` (config) | + +**Reading step 2 (the load-bearing split):** +- **2a — subtype** is the metamodel's *extension point*: the only shape that owns + custom logic. Litmus: *"would I plausibly want to attach behavior or extra + attributes to this later?"* If yes → subtype. A value that merely *serializes* as + a string is still a subtype if the **concept** has a native type or behavior of + its own. (General rule, stated abstractly so it survives un-built vocab: *a + concept with a native type or its own behavior becomes a subtype; a plain string + that just needs validating becomes a validation attribute.*) +- **2b — `@kind`** is reserved for variants *inside* a subtype that earned its place + by 2a. `@kind` on a plain `field.string` is wrong: a plain string isn't a + behavioral subtype, so there's nothing for the kinds to be *kinds of*. Never let + `@kind` become a catch-all discriminator. +- **2c — attribute** shape follows what it is: a **boolean exception-flag** whose + common case is *absent* (`@localTime` — never a default-true opt-out); a **closed + set** → enum attr with `allowedValues`; a **validation constraint** that narrows a + value without changing its type (the thing stays a plain ``, there's no + behavior to own — else it would be 2a); a **config value** (sizing, precision, + locale) → a typed attr (`@precision`/`@scale`). + +**Two corollaries that break ties:** +- **Self-documentation over economy.** Prefer a specific named attribute + (`@localTime`, `@unique`) over folding several concerns into one generic attr. A + name should tell you what it does without a per-type lookup. The *primary* + universal discriminator is already `type.subType` — don't invent a second one. +- **Same concept → same attr name; never same-name / different meaning.** If an attr + name already means something else on another type, give the new one a distinct + name rather than overload it. + +This procedure is authority-backed: **ADR-0037** is the source of truth, sequencing +ADR-0013 (physical vs logical), ADR-0023 (derive, don't invent), and ADR-0001 +(build-time native binding). + +Canonical form for common field needs — reach for these before inventing anything: | Need | Author it as | Note | |---|---|---| @@ -205,11 +249,17 @@ lives in `field.timestamp` (instant by default) + the `@localTime` naive opt-out -**Extending the metamodel (custom providers):** the same ordered test governs new -vocabulary you register. A would-be subtype that differs from an existing one only -by a property is an **attribute**, not a subtype; a string-shape validation -(email/url) is an attribute, not a subtype (its native type is still `string`). -Apply ADR-0037 before registering anything. +**Extending the metamodel (custom providers):** the same ordered procedure above +governs new vocabulary you register — apply it mechanically before registering +anything. A would-be subtype that differs from an existing one only by a *property* +is an **attribute**, not a subtype (a "short string" isn't a new field subtype — +that is `@maxLength`); a plain string that merely needs validating is a **validation +attribute**, not a subtype (its native type is still `string`, and there's no +behavior to own); a concept with its own native type or behavior is a **subtype**, +and structural variants *within* such a subtype are `@kind`. Every new first-class +element also requires a registered provider + a `registry-conformance` fixture +(ADR-0023 strict provenance), and closed enums (including any `@kind` value-set) +carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority. ### Currency diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index b44b679ed..e333b2077 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -159,12 +159,12 @@ Postgres, `field.enum` → `varchar` + `CHECK`, etc.). Codegen only ever maps the **shapes you authored** — so author them right. If you find the generator emitting the wrong column type, the fix is the field shape, not a -template hack. See "Choosing the right field shape" in the **`metaobjects-authoring`** -skill for the ordered subtype-vs-attribute-vs-`@dbColumnType` test (ADR-0037) — e.g. -arrays are `isArray: true` (never an array column type) and a native UUID is -`field.uuid` (not a string + `@dbColumnType`). When you register custom vocabulary -for a custom generator, the same ADR-0037 procedure decides whether it's a subtype or -an attribute. +template hack. See "Choosing the right shape — the general decision procedure" in the +**`metaobjects-authoring`** skill for the ordered derive→`@dbColumnType`→subtype/ +`@kind`/attribute routing (ADR-0037) — e.g. arrays are `isArray: true` (never an +array column type) and a native UUID is `field.uuid` (not a string + `@dbColumnType`). +When you register custom vocabulary for a custom generator, the same ADR-0037 +procedure decides whether it's a subtype, a `@kind` variant, or an attribute. ## Per-target output