Skip to content

Commit cf70774

Browse files
dmealingclaude
andauthored
docs: ADR-0037 refinement (behavior-based subtype test + @kind charter) + generalized decision docs (#128)
* docs(adr): ADR-0037 refinement — behavior-based subtype test + @kind charter 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n * docs(agent-context): generalize the metadata-shape decision procedure (ADR-0037) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GF9xLEQZaPus5Y6opk398n --------- Co-authored-by: Doug Mealing <noreply@anthropic.com>
1 parent 91f11ce commit cf70774

13 files changed

Lines changed: 438 additions & 167 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ Preserve the following contracts exactly across all language ports:
465465

466466
These are the load-bearing principles that have emerged through implementation. Apply them every time.
467467

468-
- **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).
468+
- **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).
469469

470470
- **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.
471471

agent-context/skills/metaobjects-authoring/SKILL.md

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -161,22 +161,66 @@ name package extends abstract overlay isArray children value
161161
Common field attributes: `@required`, `@maxLength`, `@column` (physical column
162162
name), `@default`, `@filterable`, `@sortable`.
163163

164-
### Choosing the right field shape
165-
166-
When a need doesn't obviously map to a subtype, run this ordered test (ADR-0037) —
167-
**the first that matches decides:**
168-
169-
1. **Derivable from what you already have?** → derive it, declare NOTHING new. An
170-
array is `isArray: true` (never an array column type); a bounded string is
171-
`@maxLength`; FK columns come from `identity.reference`. Don't add vocabulary for
172-
something the metadata already implies.
173-
2. **Pure physical-DB detail, native type unchanged?** → the narrow `@dbColumnType`
174-
escape hatch (e.g. an open JSON bag). Used sparingly; not a logical type.
175-
3. **Logical concept** — a *different kind* of value (its own native type, e.g.
176-
UUID, money) → a **subtype**; the *same kind* with a modifier (still a string/
177-
decimal, just constrained) → an **attribute** (`@maxLength`, `@precision`).
178-
179-
Canonical form for common needs — reach for these before inventing anything:
164+
### Choosing the right shape — the general decision procedure (ADR-0037)
165+
166+
This procedure decides the shape of **any** concept entering the metamodel — a
167+
field need today, or new vocabulary you register as a custom provider. It is not a
168+
lookup table of specific answers; it is the routing an LLM re-derives on its own
169+
for a concept it has never seen.
170+
171+
**Ask what the concept *does*, never how it stores.** The guiding question is
172+
**semantic behavior, not surface storage**: never ask *"is X a string / a number /
173+
a date?"* — ask *"what does X **do**? Does it have its own native type, behavior,
174+
or attributes (a **thing** → subtype)? Is it a structural variant of an existing
175+
thing (a **kind**)? Or does it just modify, validate, or configure an existing type
176+
(an **attribute**)?"* Shape follows behavior. Don't be misled by tools (JSON
177+
Schema, Zod) that call everything a "string format" — they only do so because
178+
JS/JSON has no native types; MetaObjects binds metadata→native types across five
179+
languages, so the call is behavioral.
180+
181+
Run the steps **in order; the first that matches decides:**
182+
183+
| # | Test | If yes → | Examples (existing vocab) |
184+
|---|---|---|---|
185+
| 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` |
186+
| 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` |
187+
| 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) |
188+
| 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 |
189+
| 2c | Otherwise it **modifies / validates / configures** an existing type | **ATTRIBUTE** (boolean flag · closed enum · validation · config) | `@localTime` (boolean exception-flag); `@maxLength`/`@precision`/`@scale` (config) |
190+
191+
**Reading step 2 (the load-bearing split):**
192+
- **2a — subtype** is the metamodel's *extension point*: the only shape that owns
193+
custom logic. Litmus: *"would I plausibly want to attach behavior or extra
194+
attributes to this later?"* If yes → subtype. A value that merely *serializes* as
195+
a string is still a subtype if the **concept** has a native type or behavior of
196+
its own. (General rule, stated abstractly so it survives un-built vocab: *a
197+
concept with a native type or its own behavior becomes a subtype; a plain string
198+
that just needs validating becomes a validation attribute.*)
199+
- **2b — `@kind`** is reserved for variants *inside* a subtype that earned its place
200+
by 2a. `@kind` on a plain `field.string` is wrong: a plain string isn't a
201+
behavioral subtype, so there's nothing for the kinds to be *kinds of*. Never let
202+
`@kind` become a catch-all discriminator.
203+
- **2c — attribute** shape follows what it is: a **boolean exception-flag** whose
204+
common case is *absent* (`@localTime` — never a default-true opt-out); a **closed
205+
set** → enum attr with `allowedValues`; a **validation constraint** that narrows a
206+
value without changing its type (the thing stays a plain `<base>`, there's no
207+
behavior to own — else it would be 2a); a **config value** (sizing, precision,
208+
locale) → a typed attr (`@precision`/`@scale`).
209+
210+
**Two corollaries that break ties:**
211+
- **Self-documentation over economy.** Prefer a specific named attribute
212+
(`@localTime`, `@unique`) over folding several concerns into one generic attr. A
213+
name should tell you what it does without a per-type lookup. The *primary*
214+
universal discriminator is already `type.subType` — don't invent a second one.
215+
- **Same concept → same attr name; never same-name / different meaning.** If an attr
216+
name already means something else on another type, give the new one a distinct
217+
name rather than overload it.
218+
219+
This procedure is authority-backed: **ADR-0037** is the source of truth, sequencing
220+
ADR-0013 (physical vs logical), ADR-0023 (derive, don't invent), and ADR-0001
221+
(build-time native binding).
222+
223+
Canonical form for common field needs — reach for these before inventing anything:
180224

181225
| Need | Author it as | Note |
182226
|---|---|---|
@@ -205,11 +249,17 @@ lives in `field.timestamp` (instant by default) + the `@localTime` naive opt-out
205249

206250
<!-- TODO(ADR-0036 Wave 3): add @format (string-shape email/url) / @relationName guidance when merged -->
207251

208-
**Extending the metamodel (custom providers):** the same ordered test governs new
209-
vocabulary you register. A would-be subtype that differs from an existing one only
210-
by a property is an **attribute**, not a subtype; a string-shape validation
211-
(email/url) is an attribute, not a subtype (its native type is still `string`).
212-
Apply ADR-0037 before registering anything.
252+
**Extending the metamodel (custom providers):** the same ordered procedure above
253+
governs new vocabulary you register — apply it mechanically before registering
254+
anything. A would-be subtype that differs from an existing one only by a *property*
255+
is an **attribute**, not a subtype (a "short string" isn't a new field subtype —
256+
that is `@maxLength`); a plain string that merely needs validating is a **validation
257+
attribute**, not a subtype (its native type is still `string`, and there's no
258+
behavior to own); a concept with its own native type or behavior is a **subtype**,
259+
and structural variants *within* such a subtype are `@kind`. Every new first-class
260+
element also requires a registered provider + a `registry-conformance` fixture
261+
(ADR-0023 strict provenance), and closed enums (including any `@kind` value-set)
262+
carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority.
213263

214264
### Currency
215265

agent-context/skills/metaobjects-codegen/SKILL.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,12 @@ Postgres, `field.enum` → `varchar` + `CHECK`, etc.).
159159

160160
Codegen only ever maps the **shapes you authored** — so author them right. If you
161161
find the generator emitting the wrong column type, the fix is the field shape, not a
162-
template hack. See "Choosing the right field shape" in the **`metaobjects-authoring`**
163-
skill for the ordered subtype-vs-attribute-vs-`@dbColumnType` test (ADR-0037) — e.g.
164-
arrays are `isArray: true` (never an array column type) and a native UUID is
165-
`field.uuid` (not a string + `@dbColumnType`). When you register custom vocabulary
166-
for a custom generator, the same ADR-0037 procedure decides whether it's a subtype or
167-
an attribute.
162+
template hack. See "Choosing the right shape — the general decision procedure" in the
163+
**`metaobjects-authoring`** skill for the ordered derive→`@dbColumnType`→subtype/
164+
`@kind`/attribute routing (ADR-0037) — e.g. arrays are `isArray: true` (never an
165+
array column type) and a native UUID is `field.uuid` (not a string + `@dbColumnType`).
166+
When you register custom vocabulary for a custom generator, the same ADR-0037
167+
procedure decides whether it's a subtype, a `@kind` variant, or an attribute.
168168

169169
## Per-target output
170170

fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -161,22 +161,66 @@ name package extends abstract overlay isArray children value
161161
Common field attributes: `@required`, `@maxLength`, `@column` (physical column
162162
name), `@default`, `@filterable`, `@sortable`.
163163

164-
### Choosing the right field shape
165-
166-
When a need doesn't obviously map to a subtype, run this ordered test (ADR-0037) —
167-
**the first that matches decides:**
168-
169-
1. **Derivable from what you already have?** → derive it, declare NOTHING new. An
170-
array is `isArray: true` (never an array column type); a bounded string is
171-
`@maxLength`; FK columns come from `identity.reference`. Don't add vocabulary for
172-
something the metadata already implies.
173-
2. **Pure physical-DB detail, native type unchanged?** → the narrow `@dbColumnType`
174-
escape hatch (e.g. an open JSON bag). Used sparingly; not a logical type.
175-
3. **Logical concept** — a *different kind* of value (its own native type, e.g.
176-
UUID, money) → a **subtype**; the *same kind* with a modifier (still a string/
177-
decimal, just constrained) → an **attribute** (`@maxLength`, `@precision`).
178-
179-
Canonical form for common needs — reach for these before inventing anything:
164+
### Choosing the right shape — the general decision procedure (ADR-0037)
165+
166+
This procedure decides the shape of **any** concept entering the metamodel — a
167+
field need today, or new vocabulary you register as a custom provider. It is not a
168+
lookup table of specific answers; it is the routing an LLM re-derives on its own
169+
for a concept it has never seen.
170+
171+
**Ask what the concept *does*, never how it stores.** The guiding question is
172+
**semantic behavior, not surface storage**: never ask *"is X a string / a number /
173+
a date?"* — ask *"what does X **do**? Does it have its own native type, behavior,
174+
or attributes (a **thing** → subtype)? Is it a structural variant of an existing
175+
thing (a **kind**)? Or does it just modify, validate, or configure an existing type
176+
(an **attribute**)?"* Shape follows behavior. Don't be misled by tools (JSON
177+
Schema, Zod) that call everything a "string format" — they only do so because
178+
JS/JSON has no native types; MetaObjects binds metadata→native types across five
179+
languages, so the call is behavioral.
180+
181+
Run the steps **in order; the first that matches decides:**
182+
183+
| # | Test | If yes → | Examples (existing vocab) |
184+
|---|---|---|---|
185+
| 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` |
186+
| 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` |
187+
| 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) |
188+
| 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 |
189+
| 2c | Otherwise it **modifies / validates / configures** an existing type | **ATTRIBUTE** (boolean flag · closed enum · validation · config) | `@localTime` (boolean exception-flag); `@maxLength`/`@precision`/`@scale` (config) |
190+
191+
**Reading step 2 (the load-bearing split):**
192+
- **2a — subtype** is the metamodel's *extension point*: the only shape that owns
193+
custom logic. Litmus: *"would I plausibly want to attach behavior or extra
194+
attributes to this later?"* If yes → subtype. A value that merely *serializes* as
195+
a string is still a subtype if the **concept** has a native type or behavior of
196+
its own. (General rule, stated abstractly so it survives un-built vocab: *a
197+
concept with a native type or its own behavior becomes a subtype; a plain string
198+
that just needs validating becomes a validation attribute.*)
199+
- **2b — `@kind`** is reserved for variants *inside* a subtype that earned its place
200+
by 2a. `@kind` on a plain `field.string` is wrong: a plain string isn't a
201+
behavioral subtype, so there's nothing for the kinds to be *kinds of*. Never let
202+
`@kind` become a catch-all discriminator.
203+
- **2c — attribute** shape follows what it is: a **boolean exception-flag** whose
204+
common case is *absent* (`@localTime` — never a default-true opt-out); a **closed
205+
set** → enum attr with `allowedValues`; a **validation constraint** that narrows a
206+
value without changing its type (the thing stays a plain `<base>`, there's no
207+
behavior to own — else it would be 2a); a **config value** (sizing, precision,
208+
locale) → a typed attr (`@precision`/`@scale`).
209+
210+
**Two corollaries that break ties:**
211+
- **Self-documentation over economy.** Prefer a specific named attribute
212+
(`@localTime`, `@unique`) over folding several concerns into one generic attr. A
213+
name should tell you what it does without a per-type lookup. The *primary*
214+
universal discriminator is already `type.subType` — don't invent a second one.
215+
- **Same concept → same attr name; never same-name / different meaning.** If an attr
216+
name already means something else on another type, give the new one a distinct
217+
name rather than overload it.
218+
219+
This procedure is authority-backed: **ADR-0037** is the source of truth, sequencing
220+
ADR-0013 (physical vs logical), ADR-0023 (derive, don't invent), and ADR-0001
221+
(build-time native binding).
222+
223+
Canonical form for common field needs — reach for these before inventing anything:
180224

181225
| Need | Author it as | Note |
182226
|---|---|---|
@@ -205,11 +249,17 @@ lives in `field.timestamp` (instant by default) + the `@localTime` naive opt-out
205249

206250
<!-- TODO(ADR-0036 Wave 3): add @format (string-shape email/url) / @relationName guidance when merged -->
207251

208-
**Extending the metamodel (custom providers):** the same ordered test governs new
209-
vocabulary you register. A would-be subtype that differs from an existing one only
210-
by a property is an **attribute**, not a subtype; a string-shape validation
211-
(email/url) is an attribute, not a subtype (its native type is still `string`).
212-
Apply ADR-0037 before registering anything.
252+
**Extending the metamodel (custom providers):** the same ordered procedure above
253+
governs new vocabulary you register — apply it mechanically before registering
254+
anything. A would-be subtype that differs from an existing one only by a *property*
255+
is an **attribute**, not a subtype (a "short string" isn't a new field subtype —
256+
that is `@maxLength`); a plain string that merely needs validating is a **validation
257+
attribute**, not a subtype (its native type is still `string`, and there's no
258+
behavior to own); a concept with its own native type or behavior is a **subtype**,
259+
and structural variants *within* such a subtype are `@kind`. Every new first-class
260+
element also requires a registered provider + a `registry-conformance` fixture
261+
(ADR-0023 strict provenance), and closed enums (including any `@kind` value-set)
262+
carry `allowedValues` in the gate (ADR-0036). ADR-0037 is the authority.
213263

214264
### Currency
215265

0 commit comments

Comments
 (0)