The fourth pillar of MetaObjects: making LLM prompt construction (and any other
rendered text artifact — emails, exports, docs, llms.txt) a first-class metamodel
capability. A template is a typed pair: a logical reference to the prompt /
output text (resolved at runtime by a provider, never inlined in metadata) and a
payload value-object that declares exactly what shape of data the template
expects.
This buys four guarantees:
- Drift detection — a renamed field on the source entity breaks the build
(
Renderer.verifyreports it), not silently degrades a prompt. - Snapshot-testability —
(payload VO, resolved text) → stringis a pure function; pin the rendered output as a fixture. - Cache-stability — a whitespace change can't silently break exact-prefix prompt-cache hits because the rendered output is byte-identical across runs and across language ports.
- Cross-language conformance — a Python eval renders exactly what the Java production server sends.
The vocabulary is template.* (the renderable unit) over a declared payload
shape — an object.value, or (since #210) a sourceless object.projection.
Mustache is the chosen template engine — it has the only published
cross-language spec + conformance suite.
A template subtype does not say what the text is about; it says which way the text travels. That is ADR-0052.
| Subtype | Direction | Use case | Generates |
|---|---|---|---|
template.prompt |
outbound, and (optionally) inbound | LLM-targeted | The payload record + the render handle. Declaring @responseRef additionally generates the response record, the response-format fragment, the parser-on-receipt and the tolerant extractor. |
template.output |
outbound only | Email / docs / config / export | The payload record + the render helper. Never a parser — nothing reads a reply to a document. |
Both carry the same generic attributes:
| Attr | Required | Purpose |
|---|---|---|
@payloadRef |
yes | The object.value — or sourceless object.projection (#210) — declaring the shape this template RENDERS |
@textRef |
yes | The 2-layer logical reference group/source resolved by a provider |
@format |
no | text / html / xml / csv / json / markdown / spreadsheet — the syntax of the rendered BODY; drives the escaper. Default: text. |
@maxChars |
no | Build-time size budget |
@owner |
no | Governance attribute |
@since |
no | Governance attribute |
template.prompt additionally carries @maxTokens, @requiredSlots, @model, and the
inbound half:
| Attr | Required | Purpose |
|---|---|---|
@responseRef |
no | The payload target a model's REPLY is parsed into. Its presence IS the request for the whole inbound tier. |
@responseFormat |
no | json | xml — the syntax of the REPLY. Default: json. |
@promptStyle |
no | guide | inline | exampleOnly — how the response-format fragment teaches the shape |
@formatand@responseFormatare two different facts, not two shapes of one.@formatis the syntax of the question you send;@responseFormat(ADR-0053) is the syntax of the answer you expect. A plain-text prompt asking for a JSON object is the common case, and the pre-ADR-0052 tier read@formatfor both — so that prompt got a strict parser and no tolerant extract, while an@format: markdowndocument got a generatedJSON.parseover rendered prose.Leaving
@responseRefoff is how you say "this prompt's reply is not machine-read."
A payload is an object.value — or a sourceless object.projection
(#210: no source.* child, own or inherited) — whose fields DECLARE the
payload's shape; a prompt's payload is a typed projection you author, so
payload bloat shows up as a diff. Every port's payload codegen is declared-type-authoritative
(#270): a field's generated type comes only from its declared field.<subType>
isArray+@objectRef, and a nested payload is a declaredfield.object @objectRefto anotherobject.value(isArray: truefor a list). Anorigin.*child on a payload field is ignored for typing — it never changes the generated type, nullability, or the nested-payload set. The caller supplies the field values at render time.
Derivation and assembly belong to projections (object.projection), which
carry the origin vocabulary — origin.passthrough, origin.aggregate (incl.
the any / all quantifiers and the collect array rollup), origin.computed
(a closed @expr grammar) and origin.first (#195) — see
source-kinds.md. #210 draws the host line hard: a
value-hosted field may carry only origin.passthrough (FR-015 parameter
lineage); the assembly origins (aggregate / computed / collection /
first) on an object.value fail load with ERR_SUBTYPE_RULE_VIOLATION. An
origin-declared payload lives on a sourceless projection instead, and
@payloadRef / @responseRef accept it at the template level. Nested payload
targets (a payload field's field.object @objectRef) stay value-only.
The named example: a WelcomePrompt greets an Author by name and includes
their post count + the first 3 post titles.
{
"metadata.root": {
"package": "acme::blog",
"children": [
{
"object.value": {
"name": "WelcomePayload",
"children": [
{ "field.string": { "name": "displayName" } },
{ "field.long": { "name": "postCount" } },
{ "field.object": { "name": "posts", "@objectRef": "PostSummary",
"isArray": true } }
]
}
},
{
"object.value": {
"name": "PostSummary",
"children": [
{ "field.string": { "name": "title" } }
]
}
},
{
"template.prompt": {
"name": "WelcomePrompt",
"@payloadRef": "WelcomePayload",
"@textRef": "lobby/welcome",
"@format": "xml",
"@maxTokens": 500
}
}
]
}
}metadata:
package: acme::blog
children:
- object.value:
name: WelcomePayload
children:
- field.string:
name: displayName
- field.long:
name: postCount
- field.object:
name: posts
objectRef: PostSummary
isArray: true
- object.value:
name: PostSummary
children:
- field.string:
name: title
- template.prompt:
name: WelcomePrompt
payloadRef: WelcomePayload
textRef: lobby/welcome
format: xml
maxTokens: 500@textRef is a 2-layer logical reference group/source (folder/file ·
table/key · collection/document). At runtime, a configured provider resolves the
reference to the actual template text:
FilesystemProvider— L1 = folder, L2 = file. The default for dev.InMemoryProvider— aMap<String,String>. Test-only.ClasspathResourceProvider— Java/Kotlin: resolves throughgetResourceAsStream.
A consumer can ship their own provider (RDB / Neo4j / Qdrant) — the engine takes
the Provider interface and delegates. Locale, A/B, dynamic, and evolutionary
prompts all live behind the provider seam without touching metadata.
For the lobby/welcome template:
<prompt>
<author name="{{displayName}}" posts="{{postCount}}"/>
<posts>
{{#posts}}
<post title="{{title}}"/>
{{/posts}}
</posts>
</prompt>…and a payload { displayName: "Ada", postCount: 12, posts: [{title: "Hello"}, {title: "Mustache"}, {title: "Prompts"}] }, every port renders byte-identical:
<prompt>
<author name="Ada" posts="12"/>
<posts>
<post title="Hello"/>
<post title="Mustache"/>
<post title="Prompts"/>
</posts>
</prompt>@metaobjectsdev/render ships the render engine + verify. Payload-VO codegen is
shared with the projection codegen path (the payload is an object.value or a
sourceless object.projection, #210).
import { render } from "@metaobjectsdev/render";
import { FilesystemProvider } from "@metaobjectsdev/render/providers";
const out: string = await render({
ref: "lobby/welcome",
payload: { displayName: "Ada", postCount: 12, posts: [{ title: "Hello" }] },
provider: new FilesystemProvider("./prompts"),
format: "xml",
});metaobjects-render ships Renderer + Provider (Classpath, Filesystem,
InMemory) + Verify. SpringPayloadGenerator (in metaobjects-codegen-spring)
emits a Java 21 record payload per template, typing every component from its
declared field (#270; matches the Kotlin reference). Host code may also pass a
Map<String,Object> to the renderer if it doesn't want the generated type.
import com.metaobjects.render.*;
Provider provider = new FilesystemProvider(Path.of("./prompts"));
String out = Renderer.render(RenderRequest.builder()
.ref("lobby/welcome")
.payload(new WelcomePromptPayload("Ada", 12L, List.of(new PostSummaryPayload("Hello"))))
.provider(provider)
.format("xml")
.build());// generated/acme/blog/prompts/WelcomePromptPayload.java
public record WelcomePromptPayload(
String displayName,
Long postCount,
java.util.List<PostSummaryPayload> posts
) {}
// generated/acme/blog/prompts/PostSummaryPayload.java
public record PostSummaryPayload(String title) {}metaobjects-metadata-ktx wraps Renderer in an idiomatic Kotlin builder.
KotlinPayloadGenerator (in codegen-kotlin) emits a @Serializable payload data
class per template, typing every property from its declared field (#270).
import com.metaobjects.metadata.ktx.render
import com.metaobjects.render.FilesystemProvider
import java.nio.file.Path
val out = render {
ref = "lobby/welcome"
payload = WelcomePromptPayload(
displayName = "Ada",
postCount = 12,
posts = listOf(PostSummaryPayload("Hello")),
)
provider = FilesystemProvider(Path.of("./prompts"))
format = "xml"
}// generated/acme/blog/prompts/WelcomePromptPayload.kt
@Serializable
data class WelcomePromptPayload(
val displayName: String,
val postCount: Long,
val posts: List<PostSummaryPayload>,
)
// generated/acme/blog/prompts/PostSummaryPayload.kt
@Serializable
data class PostSummaryPayload(val title: String)MetaObjects.Render ships the render engine + verify. MetaObjects.Codegen
ships payload-VO codegen for every declared template — the strict record is named
after the value object, not the template (for this model: record WelcomePayload /
record PostSummary), with required init-only properties named verbatim after the
metadata fields (displayName, postCount, posts). Because the name comes from the
VO, a responding prompt's @responseRef record simply IS that VO's record; there is no
second convention. You can still hand the renderer a plain object/array graph instead:
using MetaObjects.Render;
var provider = new FilesystemProvider("./prompts");
var payload = new Dictionary<string, object?>
{
["displayName"] = "Ada",
["postCount"] = 12,
["posts"] = new[] { new Dictionary<string, object?> { ["title"] = "Hello" } },
};
string output = Renderer.Render(new RenderRequest
{
Ref = "lobby/welcome",
Payload = payload,
Provider = provider,
Format = "xml",
});metaobjects.render ships the Mustache engine + Verify. The Python loader
recognizes template.* + origin.*. Payload-VO codegen is emitted (the
payload generator emits a Pydantic BaseModel per template, typed from the
declared fields (#270) — see
Response parsing (FR-006)), so a consumer can render from
the generated payload type or from a plain dict.
render takes a RenderRequest (only payload + provider are required; ref
defaults to None, format to "text"):
from metaobjects.render import FilesystemProvider
from metaobjects.render.renderer import render, RenderRequest
out = render(RenderRequest(
payload={
"displayName": "Ada",
"postCount": 12,
"posts": [{"title": "Hello"}],
},
provider=FilesystemProvider("./prompts"),
ref="lobby/welcome",
format="xml",
))Symmetric story for the reverse direction: for every template.prompt declaring
@responseRef, codegen emits a typed parser that turns a model's reply (raw text) into
that shape. The gate is @responseRef PRESENCE, never a format value — declaring a
response shape IS the request for a parser. See
ADR-0052 for
the direction rule, ADR-0010
for the cross-port principle and FR-006
for the design.
A responding prompt therefore carries TWO declared shapes and gets TWO records: the
@payloadRef request it renders outbound, and the @responseRef reply it parses. They
are usually different — the question and the answer rarely have the same fields.
Each port emits the parser in its idiomatic shape — throw-only by default, plus a Result-style "safe" variant where the language has an idiomatic precedent:
| Port | Throwing API | Result-style API | Substrate |
|---|---|---|---|
| TypeScript | parseXxx(text): T |
safeParseXxx(text) → { success, data | error } |
Zod |
| C# | XxxParser.Parse(string): T |
XxxParser.TryParse(text, out T, out string) → bool |
System.Text.Json |
| Python | parse_xxx(text: str) -> T |
— (Pythonic norm is throw-only; consumers try/except) |
Pydantic v2 |
| Kotlin | XxxParser.parseXxx(text): TPayload |
XxxParser.safeParseXxx(text): Result<TPayload> |
kotlinx.serialization.json |
| Java | XxxParser.parse(text): TPayload (throws JsonProcessingException) |
— (throw-only; the FR-010 extractLenient(loader, text) tolerant-extraction variant ships alongside parse()) |
Jackson ObjectMapper (SpringOutputParserGenerator) |
The throwing API matches the substrate's native deserialization exception
(Zod ZodError, JsonException, ValidationError, SerializationException,
JsonProcessingException). The Result-style API wraps the throwing API and
does not throw on validation failure. All five shipped ports satisfy the same
conformance fixtures
(template-prompt-response-json
and its -xml sibling).
The strict tier is JSON-only. An @responseFormat: xml reply gets the tolerant
extract and nothing strict — not for want of an XML reader (the render package ships a
forgiving one) but because strict all-or-nothing semantics layered over a REPAIRING
parser is incoherent: it would raise or accept based on how much repair happened, which
is not a contract anyone can reason about.
import acme.ai.prompts.NpcResponseParser
import acme.ai.prompts.WelcomePromptPayload
import com.metaobjects.metadata.ktx.render
// 1. Render the prompt
val promptText = render {
ref = "ai/npc-prompt"
payload = WelcomePromptPayload(scenario = "tavern-encounter", playerLevel = 4)
provider = FilesystemProvider(Path.of("./prompts"))
}
// 2. Call your LLM provider (out of scope — pick your client)
val llmResponse: String = myLlmClient.complete(promptText)
// 3. Parse the response
val npc = NpcResponseParser.parseNpcResponse(llmResponse) // throws
val safe = NpcResponseParser.safeParseNpcResponse(llmResponse) // Result<NpcResponsePayload>
safe.onSuccess { npc -> /* use it */ }.onFailure { ex -> /* log */ }TS, C#, and Python follow the same three-step pattern — render the prompt via the existing engine, call the LLM client (provider-agnostic — codegen does NOT emit provider-side schema artifacts), then parse the response with the generated parser.
| Port | File | Class/module |
|---|---|---|
| TypeScript | <PromptName>.response.ts |
parse<PromptName> + safeParse<PromptName> functions |
| C# | <PromptName>.response.cs |
static class <PromptName>Parser |
| Python | <prompt_name>_response_parser.py |
parse_<prompt_name> function |
| Kotlin | <PromptShortName>Parser.kt |
object <PromptShortName>Parser (same package as the record) |
| Java | <PromptShortName>Parser.java |
final class <PromptShortName>Parser |
The parser file is a companion to (not a replacement for) the record file — the parser
imports the response record rather than redeclaring it. Where that record comes from
differs by port, because the ports do not share a naming convention: C# names records
after the resolved VALUE OBJECT, so the response record simply IS the VO's record;
Java, Kotlin and Python name them after the TEMPLATE, so a responding prompt gets a
SECOND record, <Prompt>Response, beside <Prompt>Payload (Python puts it in its own
<prompt_name>_response.py, since the request record rejects unknown fields and a reply
record must tolerate them); TypeScript types the payload from entityFile(), which
emits per object.value regardless of any template.
meta verify walks both subtypes, catching payload ↔ template drift at build time.
On malformed metadata, generators behave slightly differently — TS throws
from renderOutputParser (aborts the run); C# / Python / Kotlin warn and skip
the malformed template (the run continues, the affected parser file is not
emitted). In practice the loader's template-validation pass rejects malformed
@payloadRef declarations before codegen runs, so this divergence is not
user-visible under normal flow; it only matters for defensive paths in
custom embedding scenarios. Tracked as a cross-port consistency item.
Parsing a model's answer is best-effort, so the parser returns a value and an
ExtractionReport classifying every field it could not populate:
| verdict | meaning |
|---|---|
EXTRACTED |
the document answered it |
DEFAULTED |
the document did not answer it; the value came from the field's @default |
LOST_OPTIONAL |
absent, no default, not required |
LOST_REQUIRED |
absent, no default, and required |
MALFORMED |
present but unusable |
The generated failure signal keys on hasLostRequired() — the generated extractor throws
on it, and Java's ExtractionResult.dataOrThrow() throws iff it is true.
A
@defaultsatisfies@required. An absent field carrying a@defaultis filled and classifiedDEFAULTED— so it is neverLOST_REQUIRED, and it can never make the generated guard fire.That is deliberate (a default is an answer), but the consequence is easy to miss: declaring a
@defaultswitches off loss detection for that field. And it propagates throughextends— adding an innocuous@defaultto a shared abstract field silently disables loss detection for every field that inherits it. A value the model never gave you then becomes indistinguishable from one it did: no exception, no warning, a healthy-looking log line.When an absent answer must not be mistaken for a given one, check
hasDefaultedRequired()/defaultedRequired()alongsidehasLostRequired(). It names exactly the required fields the document failed to answer and that were silently filled:const { data, report } = parseTriage(raw); if (report.hasLostRequired() || report.hasDefaultedRequired()) { // the model did not actually answer everything we required }(
defaulted()lists every defaulted field, required or not.) The same accessors exist in every port:defaultedRequired()/hasDefaultedRequired()in TS, Java/Kotlin and C#, anddefaulted_required()/has_defaulted_required()in Python.
Note the same reasoning applies to anything you hand-write downstream of the parser.
The report is a complete account of what survived the parser — a hand-written mapper that
turns an absent value into a plausible one (Boolean.TRUE.equals(vo.getFlag()) → false)
un-catches what the framework caught. Prefer declaring the default in metadata (where it is
reported) over defaulting in code (where it is not).
For every template, verify resolves the text, parses the {{...}} references,
and checks each one exists on the payload VO. If a template references
{{authorName}} but the payload only has displayName, the build fails.
| Port | Command |
|---|---|
| TypeScript | meta verify (CLI) |
| Java | mvn metaobjects:verify (Maven goal) |
| Kotlin | mvn metaobjects:verify (same Maven goal) |
| C# | dotnet meta verify <metadataDir> --templates <root> |
| Python | python -m metaobjects.render.verify |
- Arrays only for iteration (no object-key iteration — the engine sorts or rejects).
- No locale/number/date formatting in the engine — pre-format on the payload.
- Pinned trailing-newline + Mustache standalone-tag whitespace rules.
@formatdrives escaping via an engine-owned escaper registry (NOT the Mustache lib's default), identical across ports.- CSV / spreadsheet escapers neutralize leading
= + - @ \t \r(OWASP CSV-injection guard).
Every rule is conformance-gated by a fixture in
fixtures/render-conformance/.
The following conformance fixtures gate this feature's behavior across ports:
Template subtypes (metamodel)
fixtures/conformance/template-output-simple/—template.outputwith@payloadRef(OUTBOUND only: render, no parser)fixtures/conformance/template-prompt-simple/—template.promptwith@payloadReffixtures/conformance/template-prompt-response-json/— a RESPONDINGtemplate.prompt:@responseRefdrives the whole inbound tierfixtures/conformance/template-prompt-response-xml/—@responseFormat: xml— the tolerant extract, and no strict parserfixtures/conformance/template-output-and-prompt/— both subtypes coexist on one entityfixtures/conformance/error-template-payload-ref-unresolved/—@payloadRefmust resolve at loadfixtures/conformance/error-template-prompt-missing-payload-ref/—template.promptrequires@payloadReffixtures/conformance/error-template-required-slot-missing/— required slot declarations are checked
Origins (origin.*) — loader vocabulary (declares derivation lineage; ignored
for payload typing per #270)
fixtures/conformance/origin-passthrough-simple/—origin.passthroughcross-entity field referencefixtures/conformance/origin-aggregate-count/—origin.aggregate @agg=countfixtures/conformance/origin-aggregate-sum/—origin.aggregate @agg=sumfixtures/conformance/origin-multi-level-via/— dotted-path@viatraversal across hopsfixtures/conformance/error-origin-bad-via-path/— unresolvable@viarejectedfixtures/conformance/error-origin-bad-aggregate-fn/— unknown@aggrejectedfixtures/conformance/error-origin-passthrough-type-mismatch/— apassthroughfield whosefield.<subType>differs from its@fromsource fails withERR_PASSTHROUGH_TYPE_MISMATCHfixtures/conformance/error-origin-passthrough-array-mismatch/— apassthroughfield whose array-ness differs from its@fromsource fails withERR_PASSTHROUGH_TYPE_MISMATCHfixtures/conformance/origin-passthrough-convert-optout/—@convert: trueacknowledges a deliberate type divergence (no cast generated)
Render engine output (fixtures/render-conformance/) — byte-identical Mustache output across ports
fixtures/render-conformance/render-example-prompt/—template.promptend-to-end renderfixtures/render-conformance/render-example-email/—template.output @format=html(transactional email)fixtures/render-conformance/render-example-spreadsheet/—@format=csvwith header rowfixtures/render-conformance/render-csv-injection/— OWASP CSV-injection escaping (leading= + - @ \t \r)
Render engine semantics — Mustache-spec behavior pinned cross-port (every port's renderer must emit byte-identical output)
fixtures/render-conformance/render-dotted-path-lookup/—{{a.b.c}}traversal across nested objectsfixtures/render-conformance/render-parent-context-fallthrough/— a key missing in the current section falls through to the parent contextfixtures/render-conformance/render-empty-array-falsiness/—{{#xs}}…{{/xs}}over an empty array renders nothing (vs. iterates)fixtures/render-conformance/render-falsy-values/—false,null, empty string,0— which are truthy for{{#x}}sections (per Mustache spec, not JS truthiness)fixtures/render-conformance/render-inverted-section/—{{^x}}…{{/x}}renders whenxis falsy/absentfixtures/render-conformance/render-nested-partials/—{{>partial}}resolves through the provider, supports nestingfixtures/render-conformance/render-standalone-tag-stripping/— a line containing only a section/partial tag is removed (whitespace + newline)fixtures/render-conformance/render-raw-html-bypass/—{{{x}}}(or{{&x}}) emits raw, unescaped under@format=htmlfixtures/render-conformance/render-trailing-newline-preservation/— final-line newline preserved (prompt-cache stability invariant)fixtures/render-conformance/render-unicode-multibyte/— multibyte input handled without truncation or re-encoding
Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these
via their respective conformance runners. See docs/CONFORMANCE.md
for the per-port pass/skip ledger.
- entities.md —
object.value/ sourcelessobject.projectionare the payload host types (#210) - field-types.md — fields in payload VOs
- source-kinds.md —
source.rdb@kind: "view"for materialized payloads (FR-003) - migrations-and-drift.md — the verify pillar
- migrations/value-assembly-origins-and-source-role-shrink.md — migrating a pre-#210 payload (assembly origins on a value; nested non-value targets)
- migrations/template-direction-outbound-vs-inbound.md — migrating a pre-ADR-0052 model (
@promptStyleon an output; the inbound tier moving to@responseRef) - FR-004 spec: 2026-05-22-fr-004-cross-language-prompt-construction-design.md