Skip to content

Commit 0eb6ba7

Browse files
dmealingclaude
andcommitted
feat(loader): reject subtype-specific template attrs on the wrong subtype
The metamodel registers template attrs per subtype — prompt-only @maxtokens / @requiredSlots / @model / @responseRef, output-only @PromptStyle / @kind / @subjectRef / @htmlBodyRef / @textBodyRef, toolcall-only @toolname — but the lenient loader accepts a misplaced one and silently ignores it (e.g. @maxtokens on a template.output loaded with zero errors). _validate_templates now emits ERR_INVALID_TEMPLATE when a subtype-specific attr appears on a template whose subtype is not the one it is registered for, with a precise message. Own attrs only (not the @extends super-chain), matching the other rules in the pass. Reuses ERR_INVALID_TEMPLATE (no new error code). Spec: docs/superpowers/specs/2026-06-13-template-wrong-subtype-attr-validation.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 6f080b9 commit 0eb6ba7

3 files changed

Lines changed: 176 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Reject subtype-specific template attrs on the wrong template subtype
2+
3+
_Status: DELIVERED (Python port). Date: 2026-06-13._
4+
5+
## Problem
6+
7+
The metamodel registers template attrs **per subtype**: prompt-only attrs
8+
(`@maxTokens` / `@requiredSlots` / `@model` / `@responseRef`) on `template.prompt`,
9+
output-only attrs (`@promptStyle` / `@kind` / `@subjectRef` / `@htmlBodyRef` /
10+
`@textBodyRef`) on `template.output`, and `@toolName` on `template.toolcall`. But the
11+
loader is **lenient about misplaced attrs** — declaring a prompt-only attr on a
12+
`template.output` (or vice versa) loads with **zero errors**, the attr silently
13+
ignored:
14+
15+
```json
16+
{ "template.output": { "name": "O", "@payloadRef": "P", "@textRef": "g/o",
17+
"@format": "json", "@maxTokens": 500 } } // loads clean today
18+
```
19+
20+
This hides authoring mistakes — e.g. someone reaching for a prompt knob on an output
21+
template gets no signal that it does nothing.
22+
23+
## Decision
24+
25+
`_validate_templates` (the existing template validation pass that already owns the
26+
`@kind`/email cross-field rules) now emits **`ERR_INVALID_TEMPLATE`** when a
27+
subtype-specific attr appears on a template whose subtype is not the one it is
28+
registered for. The existing `ERR_INVALID_TEMPLATE` code is reused (no new error code
29+
→ no cross-port code-registration churn), with a precise message:
30+
31+
```
32+
template.output "O" carries @maxTokens, which is only valid on template.prompt
33+
```
34+
35+
The check reads the node's **own** attrs (`tpl.attr(...)`, not the `@extends`
36+
super-chain), matching every other rule in the pass. A `_TEMPLATE_SUBTYPE_ONLY_ATTRS`
37+
map encodes the prompt/output/toolcall-only attrs — mirroring the per-subtype
38+
`TEMPLATE_ATTRS_MAP` split the other ports already carry.
39+
40+
## Scope
41+
42+
- **In:** the subtype-specific prompt/output/toolcall attrs landing on the wrong
43+
subtype — the exact "output vs prompt attribute" mistake.
44+
- **Out (deliberately):** a general "reject *any* unregistered attr on a template"
45+
strict gate. That is a broader back-compat + cross-port decision (it would also
46+
reject typos like `@totallyFake`), and is left as a possible follow-up.
47+
48+
## Tests
49+
50+
`tests/unit/test_template_wrong_subtype_attrs.py``@maxTokens` on output,
51+
`@promptStyle` on prompt, and `@toolName` on prompt are each rejected; the same attrs
52+
on their own subtype load clean. Full non-integration suite green.
53+
54+
## Cross-port follow-up
55+
56+
This ships in the **Python** port (the one driving the need). TS / Java / C# / Kotlin
57+
should add the equivalent check, after which a shared `fixtures/conformance/error-*`
58+
fixture can pin it cross-port. The fixture is intentionally **not** added here — a
59+
shared error fixture would red-list the other ports' conformance runners until they
60+
implement the rule.

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,23 @@
8888
from ..meta.core.identity.identity_constants import IDENTITY_ATTR_FIELDS
8989
from ..source import resolved_source
9090

91+
# A subtype-specific template attr is valid ONLY on the subtype it is registered
92+
# for. The metamodel registers these per-subtype (see the core_types template block),
93+
# but the lenient loader does not reject a misplaced one — _validate_templates does.
94+
# Mirrors the per-subtype TEMPLATE_ATTRS_MAP split across the other ports.
95+
_TEMPLATE_SUBTYPE_ONLY_ATTRS: dict[str, str] = {
96+
tc.TEMPLATE_ATTR_MAX_TOKENS: tc.TEMPLATE_SUBTYPE_PROMPT,
97+
tc.TEMPLATE_ATTR_REQUIRED_SLOTS: tc.TEMPLATE_SUBTYPE_PROMPT,
98+
tc.TEMPLATE_ATTR_MODEL: tc.TEMPLATE_SUBTYPE_PROMPT,
99+
tc.TEMPLATE_ATTR_RESPONSE_REF: tc.TEMPLATE_SUBTYPE_PROMPT,
100+
tc.TEMPLATE_ATTR_PROMPT_STYLE: tc.TEMPLATE_SUBTYPE_OUTPUT,
101+
tc.TEMPLATE_ATTR_KIND: tc.TEMPLATE_SUBTYPE_OUTPUT,
102+
tc.TEMPLATE_ATTR_SUBJECT_REF: tc.TEMPLATE_SUBTYPE_OUTPUT,
103+
tc.TEMPLATE_ATTR_HTML_BODY_REF: tc.TEMPLATE_SUBTYPE_OUTPUT,
104+
tc.TEMPLATE_ATTR_TEXT_BODY_REF: tc.TEMPLATE_SUBTYPE_OUTPUT,
105+
tc.TEMPLATE_ATTR_TOOL_NAME: tc.TEMPLATE_SUBTYPE_TOOLCALL,
106+
}
107+
91108
# ---------------------------------------------------------------------------
92109
# Public entry point
93110
# ---------------------------------------------------------------------------
@@ -1434,6 +1451,21 @@ def _validate_templates(root: MetaData, errors: list[MetaError]) -> None:
14341451
payload_ref = tpl.attr(tc.TEMPLATE_ATTR_PAYLOAD_REF)
14351452
has_payload_ref = isinstance(payload_ref, str) and payload_ref
14361453

1454+
# --- subtype-specific attr on the wrong subtype ---
1455+
# e.g. @maxTokens (prompt-only) on a template.output, or @promptStyle
1456+
# (output-only) on a template.prompt. tpl.attr() reads the node's own
1457+
# attrs (not the @extends super-chain), matching the other checks here.
1458+
for attr_name, allowed_sub in _TEMPLATE_SUBTYPE_ONLY_ATTRS.items():
1459+
if tpl.attr(attr_name) is not None and tpl.sub_type != allowed_sub:
1460+
errors.append(MetaError(
1461+
code=ErrorCode.ERR_INVALID_TEMPLATE,
1462+
message=(
1463+
f'template.{tpl.sub_type} "{tpl.name}" carries @{attr_name}, '
1464+
f"which is only valid on template.{allowed_sub}"
1465+
),
1466+
envelope=tpl.source,
1467+
))
1468+
14371469
# --- @kind / textRef / email part-ref cross-field rules ---
14381470
# template.output is either a document (@kind absent/"document" -> @textRef
14391471
# required) or an email (@kind="email" -> @subjectRef + @htmlBodyRef required,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""A subtype-specific template attr must appear ONLY on its own subtype.
2+
3+
The metamodel registers prompt-only attrs (@maxTokens / @requiredSlots / @model /
4+
@responseRef) on ``template.prompt`` and output-only attrs (@promptStyle / @kind /
5+
@subjectRef / @htmlBodyRef / @textBodyRef) on ``template.output`` — but the lenient
6+
loader does not reject a misplaced one. ``_validate_templates`` turns that into a
7+
hard ``ERR_INVALID_TEMPLATE`` so e.g. ``@maxTokens`` on a ``template.output`` fails
8+
the build instead of being silently ignored.
9+
"""
10+
from __future__ import annotations
11+
12+
import json
13+
14+
from metaobjects import InMemoryStringSource, MetaDataFormat, MetaDataLoader
15+
from metaobjects.errors import ErrorCode
16+
17+
18+
def _load(*template_nodes: dict) -> list:
19+
doc = {
20+
"metadata.root": {
21+
"package": "acme",
22+
"children": [
23+
{"object.value": {"name": "P", "children": [{"field.string": {"name": "x"}}]}},
24+
*template_nodes,
25+
],
26+
}
27+
}
28+
result = MetaDataLoader().load([
29+
InMemoryStringSource(json.dumps(doc), id="m.json", format=MetaDataFormat.JSON)
30+
])
31+
return result.errors
32+
33+
34+
def _has_template_err(errors: list, needle: str) -> bool:
35+
return any(
36+
e.code == ErrorCode.ERR_INVALID_TEMPLATE and needle in e.message for e in errors
37+
)
38+
39+
40+
def test_prompt_only_attr_on_output_is_rejected() -> None:
41+
errors = _load({
42+
"template.output": {
43+
"name": "O", "@payloadRef": "P", "@textRef": "g/o",
44+
"@format": "json", "@maxTokens": 500,
45+
}
46+
})
47+
assert _has_template_err(errors, "maxTokens"), f"expected @maxTokens rejection, got {errors}"
48+
49+
50+
def test_output_only_attr_on_prompt_is_rejected() -> None:
51+
errors = _load({
52+
"template.prompt": {
53+
"name": "Pr", "@payloadRef": "P", "@textRef": "g/p", "@promptStyle": "guide",
54+
}
55+
})
56+
assert _has_template_err(errors, "promptStyle"), f"expected @promptStyle rejection, got {errors}"
57+
58+
59+
def test_toolcall_only_attr_on_prompt_is_rejected() -> None:
60+
errors = _load({
61+
"template.prompt": {
62+
"name": "Pr", "@payloadRef": "P", "@textRef": "g/p", "@toolName": "do_it",
63+
}
64+
})
65+
assert _has_template_err(errors, "toolName"), f"expected @toolName rejection, got {errors}"
66+
67+
68+
def test_prompt_only_attr_on_its_own_prompt_loads_clean() -> None:
69+
errors = _load({
70+
"template.prompt": {
71+
"name": "Pr", "@payloadRef": "P", "@textRef": "g/p", "@maxTokens": 500,
72+
}
73+
})
74+
assert errors == [], f"valid @maxTokens on template.prompt should load clean, got {errors}"
75+
76+
77+
def test_output_only_attr_on_its_own_output_loads_clean() -> None:
78+
errors = _load({
79+
"template.output": {
80+
"name": "O", "@payloadRef": "P", "@textRef": "g/o",
81+
"@format": "json", "@promptStyle": "guide",
82+
}
83+
})
84+
assert errors == [], f"valid @promptStyle on template.output should load clean, got {errors}"

0 commit comments

Comments
 (0)