Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Reject subtype-specific template attrs on the wrong template subtype

_Status: DELIVERED (Python port). Date: 2026-06-13._

## Problem

The metamodel registers template attrs **per subtype**: prompt-only attrs
(`@maxTokens` / `@requiredSlots` / `@model` / `@responseRef`) on `template.prompt`,
output-only attrs (`@promptStyle` / `@kind` / `@subjectRef` / `@htmlBodyRef` /
`@textBodyRef`) on `template.output`, and `@toolName` on `template.toolcall`. But the
loader is **lenient about misplaced attrs** — declaring a prompt-only attr on a
`template.output` (or vice versa) loads with **zero errors**, the attr silently
ignored:

```json
{ "template.output": { "name": "O", "@payloadRef": "P", "@textRef": "g/o",
"@format": "json", "@maxTokens": 500 } } // loads clean today
```

This hides authoring mistakes — e.g. someone reaching for a prompt knob on an output
template gets no signal that it does nothing.

## Decision

`_validate_templates` (the existing template validation pass that already owns the
`@kind`/email cross-field rules) now emits **`ERR_INVALID_TEMPLATE`** when a
subtype-specific attr appears on a template whose subtype is not the one it is
registered for. The existing `ERR_INVALID_TEMPLATE` code is reused (no new error code
→ no cross-port code-registration churn), with a precise message:

```
template.output "O" carries @maxTokens, which is only valid on template.prompt
```

The check reads the node's **own** attrs (`tpl.attr(...)`, not the `@extends`
super-chain), matching every other rule in the pass. A `_TEMPLATE_SUBTYPE_ONLY_ATTRS`
map encodes the prompt/output/toolcall-only attrs — mirroring the per-subtype
`TEMPLATE_ATTRS_MAP` split the other ports already carry.

## Scope

- **In:** the subtype-specific prompt/output/toolcall attrs landing on the wrong
subtype — the exact "output vs prompt attribute" mistake.
- **Out (deliberately):** a general "reject *any* unregistered attr on a template"
strict gate. That is a broader back-compat + cross-port decision (it would also
reject typos like `@totallyFake`), and is left as a possible follow-up.

## Tests

`tests/unit/test_template_wrong_subtype_attrs.py` — `@maxTokens` on output,
`@promptStyle` on prompt, and `@toolName` on prompt are each rejected; the same attrs
on their own subtype load clean. Full non-integration suite green.

## Cross-port follow-up

This ships in the **Python** port (the one driving the need). TS / Java / C# / Kotlin
should add the equivalent check, after which a shared `fixtures/conformance/error-*`
fixture can pin it cross-port. The fixture is intentionally **not** added here — a
shared error fixture would red-list the other ports' conformance runners until they
implement the rule.
32 changes: 32 additions & 0 deletions server/python/src/metaobjects/loader/validation_passes.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,23 @@
from ..meta.core.identity.identity_constants import IDENTITY_ATTR_FIELDS
from ..source import resolved_source

# A subtype-specific template attr is valid ONLY on the subtype it is registered
# for. The metamodel registers these per-subtype (see the core_types template block),
# but the lenient loader does not reject a misplaced one — _validate_templates does.
# Mirrors the per-subtype TEMPLATE_ATTRS_MAP split across the other ports.
_TEMPLATE_SUBTYPE_ONLY_ATTRS: dict[str, str] = {
tc.TEMPLATE_ATTR_MAX_TOKENS: tc.TEMPLATE_SUBTYPE_PROMPT,
tc.TEMPLATE_ATTR_REQUIRED_SLOTS: tc.TEMPLATE_SUBTYPE_PROMPT,
tc.TEMPLATE_ATTR_MODEL: tc.TEMPLATE_SUBTYPE_PROMPT,
tc.TEMPLATE_ATTR_RESPONSE_REF: tc.TEMPLATE_SUBTYPE_PROMPT,
tc.TEMPLATE_ATTR_PROMPT_STYLE: tc.TEMPLATE_SUBTYPE_OUTPUT,
tc.TEMPLATE_ATTR_KIND: tc.TEMPLATE_SUBTYPE_OUTPUT,
tc.TEMPLATE_ATTR_SUBJECT_REF: tc.TEMPLATE_SUBTYPE_OUTPUT,
tc.TEMPLATE_ATTR_HTML_BODY_REF: tc.TEMPLATE_SUBTYPE_OUTPUT,
tc.TEMPLATE_ATTR_TEXT_BODY_REF: tc.TEMPLATE_SUBTYPE_OUTPUT,
tc.TEMPLATE_ATTR_TOOL_NAME: tc.TEMPLATE_SUBTYPE_TOOLCALL,
}

# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1434,6 +1451,21 @@ def _validate_templates(root: MetaData, errors: list[MetaError]) -> None:
payload_ref = tpl.attr(tc.TEMPLATE_ATTR_PAYLOAD_REF)
has_payload_ref = isinstance(payload_ref, str) and payload_ref

# --- subtype-specific attr on the wrong subtype ---
# e.g. @maxTokens (prompt-only) on a template.output, or @promptStyle
# (output-only) on a template.prompt. tpl.attr() reads the node's own
# attrs (not the @extends super-chain), matching the other checks here.
for attr_name, allowed_sub in _TEMPLATE_SUBTYPE_ONLY_ATTRS.items():
if tpl.attr(attr_name) is not None and tpl.sub_type != allowed_sub:
errors.append(MetaError(
code=ErrorCode.ERR_INVALID_TEMPLATE,
message=(
f'template.{tpl.sub_type} "{tpl.name}" carries @{attr_name}, '
f"which is only valid on template.{allowed_sub}"
),
envelope=tpl.source,
))

# --- @kind / textRef / email part-ref cross-field rules ---
# template.output is either a document (@kind absent/"document" -> @textRef
# required) or an email (@kind="email" -> @subjectRef + @htmlBodyRef required,
Expand Down
84 changes: 84 additions & 0 deletions server/python/tests/unit/test_template_wrong_subtype_attrs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""A subtype-specific template attr must appear ONLY on its own subtype.

The metamodel registers prompt-only attrs (@maxTokens / @requiredSlots / @model /
@responseRef) on ``template.prompt`` and output-only attrs (@promptStyle / @kind /
@subjectRef / @htmlBodyRef / @textBodyRef) on ``template.output`` — but the lenient
loader does not reject a misplaced one. ``_validate_templates`` turns that into a
hard ``ERR_INVALID_TEMPLATE`` so e.g. ``@maxTokens`` on a ``template.output`` fails
the build instead of being silently ignored.
"""
from __future__ import annotations

import json

from metaobjects import InMemoryStringSource, MetaDataFormat, MetaDataLoader
from metaobjects.errors import ErrorCode


def _load(*template_nodes: dict) -> list:
doc = {
"metadata.root": {
"package": "acme",
"children": [
{"object.value": {"name": "P", "children": [{"field.string": {"name": "x"}}]}},
*template_nodes,
],
}
}
result = MetaDataLoader().load([
InMemoryStringSource(json.dumps(doc), id="m.json", format=MetaDataFormat.JSON)
])
return result.errors


def _has_template_err(errors: list, needle: str) -> bool:
return any(
e.code == ErrorCode.ERR_INVALID_TEMPLATE and needle in e.message for e in errors
)


def test_prompt_only_attr_on_output_is_rejected() -> None:
errors = _load({
"template.output": {
"name": "O", "@payloadRef": "P", "@textRef": "g/o",
"@format": "json", "@maxTokens": 500,
}
})
assert _has_template_err(errors, "maxTokens"), f"expected @maxTokens rejection, got {errors}"


def test_output_only_attr_on_prompt_is_rejected() -> None:
errors = _load({
"template.prompt": {
"name": "Pr", "@payloadRef": "P", "@textRef": "g/p", "@promptStyle": "guide",
}
})
assert _has_template_err(errors, "promptStyle"), f"expected @promptStyle rejection, got {errors}"


def test_toolcall_only_attr_on_prompt_is_rejected() -> None:
errors = _load({
"template.prompt": {
"name": "Pr", "@payloadRef": "P", "@textRef": "g/p", "@toolName": "do_it",
}
})
assert _has_template_err(errors, "toolName"), f"expected @toolName rejection, got {errors}"


def test_prompt_only_attr_on_its_own_prompt_loads_clean() -> None:
errors = _load({
"template.prompt": {
"name": "Pr", "@payloadRef": "P", "@textRef": "g/p", "@maxTokens": 500,
}
})
assert errors == [], f"valid @maxTokens on template.prompt should load clean, got {errors}"


def test_output_only_attr_on_its_own_output_loads_clean() -> None:
errors = _load({
"template.output": {
"name": "O", "@payloadRef": "P", "@textRef": "g/o",
"@format": "json", "@promptStyle": "guide",
}
})
assert errors == [], f"valid @promptStyle on template.output should load clean, got {errors}"
Loading