Skip to content

Commit 9162f87

Browse files
dmealingclaude
andcommitted
fix(loader): exempt abstract nodes from the required-attr check (#236)
The generic required-attr check flagged an ABSTRACT node for a missing required attr, so an abstract `template.prompt` that hoists shared children but leaves `@payloadRef` to its concrete subtypes (or to `extends`) failed to load — forcing every concrete prompt to restate it. An abstract is a template, not an instantiated node, so it is now exempt; enforcement stays at the concrete level (a concrete node's RESOLVING attr set must still satisfy the requirement — a concrete missing @payloadRef, inherited or own, still errors ERR_MISSING_REQUIRED_ATTR). All four loader ports already read the resolving set for this check (so a concrete inheriting via extends was already satisfied); the #236 diagnosis ("own-only") was slightly off — the real gap was the abstract exemption. Fixed identically in TS/Java/Python/C# (Kotlin inherits the JVM loader); consistent with ADR-0039. Gated by the abstract-template-prompt-hoists-payloadref conformance fixture (254 metamodel fixtures; TS/Java/Python/C# green) + TS unit tests (abstract-omits loads; concrete-inherits loads; concrete-missing still errors). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014xy8powhHYJ6gfFt9Ut8dL
1 parent 536bab7 commit 9162f87

10 files changed

Lines changed: 170 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Fixed — an abstract `template.prompt` may omit a required `@payloadRef` (#236)
11+
12+
**Cross-port** (loader — TS / Java / Python / C#; Kotlin inherits the JVM loader). The generic
13+
required-attr check flagged an **abstract** node for a missing required attr, so an abstract
14+
`template.prompt` that hoists shared children but leaves `@payloadRef` to its concrete subtypes
15+
(or to `extends`) failed to load — forcing every concrete prompt to restate it. An abstract is
16+
a template, not an instantiated node, so it's now **exempt** from the required-attr check;
17+
enforcement stays at the concrete level (a concrete node's resolving attr set must still satisfy
18+
the requirement — a concrete missing `@payloadRef`, inherited or own, still errors
19+
`ERR_MISSING_REQUIRED_ATTR`). Consistent with ADR-0039 (all four ports already read the
20+
resolving set for this check; the fix is the abstract exemption). Gated by the
21+
`abstract-template-prompt-hoists-payloadref` conformance fixture across all four ports.
22+
1023
### Fixed — empty-string column `@default: ""` no longer drifts on sqlite/d1 (#235)
1124

1225
**npm-only** (`migrate-ts`). `buildExpectedSchema` dropped an empty-string default with a

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ _Last refreshed 2026-07-26._
3030
- **Kotlin**`codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres.
3131

3232
**Cross-port conformance corpora** (every port runs the shared corpus):
33-
- Metamodel: `fixtures/conformance/` (253 fixtures; 19 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
33+
- Metamodel: `fixtures/conformance/` (254 fixtures; 19 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
3434
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
3535
- Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".)
3636
- API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed.

docs/CONFORMANCE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l`.
2525

2626
| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
2727
|---|---|---|---|---|---|---|
28-
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 253 ||| inherits via `metadata-ktx` |||
28+
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 254 ||| inherits via `metadata-ktx` |||
2929
| [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 15 | 15 / 15 | 14 / 15 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 14 / 15 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 15 / 15 |
3030
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 ||| inherits via Java |||
3131
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 ||||||
@@ -69,7 +69,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.
6969

7070
## Fixture-to-doc mapping
7171

72-
### `fixtures/conformance/` — metamodel loader + canonical serializer (253)
72+
### `fixtures/conformance/` — metamodel loader + canonical serializer (254)
7373

7474
| Fixture prefix | Feature doc |
7575
|---|---|
@@ -139,7 +139,7 @@ the port's own GENERATED API artifact booted over HTTP.
139139

140140
## Orphaned fixtures (tested but not yet documented)
141141

142-
The fixtures in the six corpora mapped above (metamodel 253 + yaml 15 + verify 31
142+
The fixtures in the six corpora mapped above (metamodel 254 + yaml 15 + verify 31
143143
+ render 15 + persistence 30 + api-contract 41) each map to a feature doc. None
144144
are orphaned today. The remaining corpora in the totals table gate tooling
145145
contracts (registry manifests, provider composition, agent context, docs emit)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme::ai",
4+
"children": [
5+
{
6+
"object.value": {
7+
"name": "AuthorBrief",
8+
"children": [
9+
{
10+
"field.string": {
11+
"name": "displayName"
12+
}
13+
}
14+
]
15+
}
16+
},
17+
{
18+
"template.prompt": {
19+
"name": "BasePrompt",
20+
"abstract": true,
21+
"@format": "xml",
22+
"@textRef": "prompt/base"
23+
}
24+
},
25+
{
26+
"template.prompt": {
27+
"name": "ConcretePrompt",
28+
"extends": "acme::ai::BasePrompt",
29+
"@payloadRef": "acme::ai::AuthorBrief",
30+
"@textRef": "prompt/concrete"
31+
}
32+
}
33+
]
34+
}
35+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme::ai",
4+
"children": [
5+
{
6+
"object.value": {
7+
"name": "AuthorBrief",
8+
"children": [
9+
{ "field.string": { "name": "displayName" } }
10+
]
11+
}
12+
},
13+
{
14+
"template.prompt": {
15+
"name": "BasePrompt",
16+
"abstract": true,
17+
"@textRef": "prompt/base",
18+
"@format": "xml"
19+
}
20+
},
21+
{
22+
"template.prompt": {
23+
"name": "ConcretePrompt",
24+
"extends": "acme::ai::BasePrompt",
25+
"@payloadRef": "acme::ai::AuthorBrief",
26+
"@textRef": "prompt/concrete"
27+
}
28+
}
29+
]
30+
}
31+
}

server/csharp/MetaObjects/Loader/ValidationPasses.cs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1760,15 +1760,21 @@ private static void ValidateAttrSchemaNode(
17601760
// --- Check 1: required attrs present ---
17611761
// Use Attrs() (effective = own + inherited) so a node that legitimately
17621762
// inherits a required attr from its super is not flagged as missing it.
1763-
var effective = node.Attrs();
1764-
foreach (var spec in byName.Values)
1763+
// #236: an ABSTRACT node is a template, not instantiated — it may omit a required
1764+
// attr for concrete subtypes / `extends` to supply. Enforcement stays at the
1765+
// concrete level (a concrete's resolving Attrs() must satisfy it). ADR-0039.
1766+
if (!node.IsAbstract)
17651767
{
1766-
if (spec.Required && !effective.ContainsKey(spec.Name))
1768+
var effective = node.Attrs();
1769+
foreach (var spec in byName.Values)
17671770
{
1768-
errors.Add(new MetaError(
1769-
$"{NodeLabel(node)} is missing required attribute '@{spec.Name}'",
1770-
ErrorCode.ERR_MISSING_REQUIRED_ATTR,
1771-
Envelope: node.Source));
1771+
if (spec.Required && !effective.ContainsKey(spec.Name))
1772+
{
1773+
errors.Add(new MetaError(
1774+
$"{NodeLabel(node)} is missing required attribute '@{spec.Name}'",
1775+
ErrorCode.ERR_MISSING_REQUIRED_ATTR,
1776+
Envelope: node.Source));
1777+
}
17721778
}
17731779
}
17741780

server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,11 @@ private static void validateRequiredAttrsNode(MetaData node, MetaDataRegistry re
352352
TypeDefinition def = registry.getTypeDefinition(type, subType);
353353
if (def == null) return; // unregistered (test scaffold etc.) — skip silently
354354

355+
// #236: an ABSTRACT node is a template, not instantiated — it may omit a required
356+
// attr for concrete subtypes / extends: to supply. Enforcement stays at the
357+
// concrete level (a concrete's effective attrs must satisfy it). ADR-0039.
358+
if (isAbstract(node)) return;
359+
355360
// Collect required ATTR requirements: direct first (wins on name
356361
// collision), then inherited for names not directly declared.
357362
Map<String, ChildRequirement> requiredAttrs = new java.util.LinkedHashMap<>();

server/python/src/metaobjects/loader/validation_passes.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -437,18 +437,22 @@ def _validate_attr_schema(
437437

438438
# --- Check 1: required attrs must be present (uses node.attrs() = effective,
439439
# so an inherited attr from the super chain satisfies the requirement) ---
440-
present_attrs = node.attrs()
441-
for schema in schemas:
442-
if not schema.required:
443-
continue
444-
if schema.name not in present_attrs:
445-
errors.append(
446-
MetaError(
447-
f"{_node_label(node)} is missing required attribute '@{schema.name}'",
448-
ErrorCode.ERR_MISSING_REQUIRED_ATTR,
449-
envelope=node.source,
440+
# #236: an ABSTRACT node is a template, not instantiated — it may omit a required
441+
# attr for concrete subtypes / `extends` to supply. Enforcement stays at the
442+
# concrete level (a concrete's resolving attrs() must satisfy it). ADR-0039.
443+
if not node.is_abstract:
444+
present_attrs = node.attrs()
445+
for schema in schemas:
446+
if not schema.required:
447+
continue
448+
if schema.name not in present_attrs:
449+
errors.append(
450+
MetaError(
451+
f"{_node_label(node)} is missing required attribute '@{schema.name}'",
452+
ErrorCode.ERR_MISSING_REQUIRED_ATTR,
453+
envelope=node.source,
454+
)
450455
)
451-
)
452456

453457
# --- Checks 2 + 3: own attrs only (inherited attrs were already checked on
454458
# the node that declared them; re-checking would double-report) ---

server/typescript/packages/metadata/src/attr-schema-validate.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -225,15 +225,25 @@ function validateNode(
225225
// Contrast with Checks 2+3 below, which iterate own attrs only: inherited attrs
226226
// were already validated on the node that declared them, so re-checking would
227227
// double-report. This mirrors the effective-vs-own split in subtype-rules.ts.
228-
const effective = node.attrs();
229-
for (const spec of byName.values()) {
230-
if (spec.required && !effective.has(spec.name)) {
231-
errors.push(
232-
new ParseError(
233-
`${nodeLabel(node)} is missing required attribute '@${spec.name}'`,
234-
{ code: "ERR_MISSING_REQUIRED_ATTR", source: node.source },
235-
),
236-
);
228+
//
229+
// #236: an ABSTRACT node is a template, not an instantiated node — it may omit a
230+
// required attr for concrete subtypes / `extends` to supply (e.g. an abstract
231+
// `template.prompt` that hoists shared children but leaves `@payloadRef` to its
232+
// concretes). Enforcement stays at the CONCRETE level: a concrete node's resolving
233+
// attrs() must satisfy the requirement (a concrete missing it — inherited or own —
234+
// still errors). Consistent with ADR-0039 (resolving-default; abstract = the
235+
// metamodel's incompleteness escape).
236+
if (!node.isAbstract) {
237+
const effective = node.attrs();
238+
for (const spec of byName.values()) {
239+
if (spec.required && !effective.has(spec.name)) {
240+
errors.push(
241+
new ParseError(
242+
`${nodeLabel(node)} is missing required attribute '@${spec.name}'`,
243+
{ code: "ERR_MISSING_REQUIRED_ATTR", source: node.source },
244+
),
245+
);
246+
}
237247
}
238248
}
239249

server/typescript/packages/metadata/test/template-validation.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,38 @@ describe("template load-time validation", () => {
120120
expect(errs.length).toBeGreaterThan(0);
121121
});
122122
});
123+
124+
// ---------------------------------------------------------------------------
125+
// #236 — an ABSTRACT template.prompt may omit the required @payloadRef (concrete
126+
// subtypes / extends supply it). The required-attr check reads the RESOLVING set
127+
// (a concrete inheriting via extends is satisfied) and EXEMPTS abstract nodes (a
128+
// template is not instantiated; its incompleteness is fine). Consistent with ADR-0039.
129+
// ---------------------------------------------------------------------------
130+
describe("#236 abstract template.prompt may omit required @payloadRef", () => {
131+
test("abstract WITHOUT @payloadRef + concrete extends supplying it → loads", async () => {
132+
const errs = await load([
133+
authorBrief,
134+
{ "template.prompt": { name: "BasePrompt", abstract: true, "@textRef": "p/base" } },
135+
{ "template.prompt": { name: "Concrete", extends: "test::BasePrompt", "@payloadRef": "AuthorBrief", "@textRef": "p/c" } },
136+
]);
137+
expect(errs).toEqual([]);
138+
});
139+
140+
test("concrete INHERITS @payloadRef from an abstract via extends → loads (resolving)", async () => {
141+
const errs = await load([
142+
authorBrief,
143+
{ "template.prompt": { name: "BasePrompt", abstract: true, "@payloadRef": "AuthorBrief", "@textRef": "p/base" } },
144+
{ "template.prompt": { name: "Concrete", extends: "test::BasePrompt", "@textRef": "p/c" } },
145+
]);
146+
expect(errs).toEqual([]);
147+
});
148+
149+
test("CONCRETE missing @payloadRef (nowhere in its chain) → ERR_MISSING_REQUIRED_ATTR", async () => {
150+
const errs = await load([
151+
authorBrief,
152+
{ "template.prompt": { name: "BasePrompt", abstract: true, "@textRef": "p/base" } },
153+
{ "template.prompt": { name: "Concrete", extends: "test::BasePrompt", "@textRef": "p/c" } },
154+
]);
155+
expect(errs.map((e) => e.code)).toContain("ERR_MISSING_REQUIRED_ATTR");
156+
});
157+
});

0 commit comments

Comments
 (0)