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) + origin.* (the projection
fields that build the payload VO). Mustache is the chosen template engine — it has
the only published cross-language spec + conformance suite.
| Subtype | Use case | Carries |
|---|---|---|
template.prompt |
LLM-targeted | @maxTokens, @requiredSlots, @model (in addition to the generic attrs) |
template.output |
Email / docs / config / export | Just the generic attrs |
Both carry the same generic attributes:
| Attr | Required | Purpose |
|---|---|---|
@payloadRef |
yes | The object.value view-object declaring the payload shape |
@textRef |
yes | The 2-layer logical reference group/source resolved by a provider |
@format |
no | text / html / xml / csv / json / markdown / spreadsheet — drives the escaper. Default: text. |
@maxChars |
no | Build-time size budget |
@owner |
no | Governance attribute |
@since |
no | Governance attribute |
A payload is an object.value view-object whose fields each declare an origin.*
child. Three origin subtypes:
| Origin | Behavior |
|---|---|
origin.passthrough @from "Entity.field" |
Payload property type matches the source field |
origin.aggregate @agg <count|sum|avg|min|max> |
count → Long; avg → Double; others match source field |
origin.collection @via "Parent.rel" |
List<NestedPayload> — assembled from a relationship |
These are the payload-assembly origins. Projection read models (object.projection over an
entity) carry a fuller origin vocabulary — the @agg quantifiers any / all and the collect
array rollup, plus origin.computed (a closed @expr grammar) and origin.first (#195) — see
source-kinds.md.
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",
"children": [ { "origin.passthrough": { "@from": "Author.name" } } ] } },
{ "field.long": { "name": "postCount",
"children": [ { "origin.aggregate": {
"@agg": "count", "@of": "Post.id", "@via": "Author.posts" } } ] } },
{ "field.object": { "name": "posts", "@objectRef": "PostSummary",
"children": [ { "origin.collection": { "@via": "Author.posts" } } ] } }
]
}
},
{
"object.value": {
"name": "PostSummary",
"children": [
{ "field.string": { "name": "title",
"children": [ { "origin.passthrough": { "@from": "Post.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
children:
- origin.passthrough: { from: Author.name }
- field.long:
name: postCount
children:
- origin.aggregate:
agg: count
of: Post.id
via: Author.posts
- field.object:
name: posts
objectRef: PostSummary
children:
- origin.collection: { via: Author.posts }
- object.value:
name: PostSummary
children:
- field.string:
name: title
children:
- origin.passthrough: { from: Post.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).
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, resolving all three origin
subtypes (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 WelcomePayload("Ada", 12L, List.of(new PostSummary("Hello"))))
.provider(provider)
.format("xml")
.build());// generated/acme/blog/prompts/WelcomePromptPayload.java
public record WelcomePromptPayload(
String displayName,
Long postCount,
java.util.List<PostSummary> posts
) {}
public record PostSummary(String title) {}metaobjects-metadata-ktx wraps Renderer in an idiomatic Kotlin builder.
KotlinPayloadGenerator (in codegen-kotlin) emits a @Serializable payload data
class per template, resolving all three origin subtypes.
import com.metaobjects.metadata.ktx.render
import com.metaobjects.render.FilesystemProvider
import java.nio.file.Path
val out = render {
ref = "lobby/welcome"
payload = WelcomePayload(
displayName = "Ada",
postCount = 12,
posts = listOf(PostSummary("Hello")),
)
provider = FilesystemProvider(Path.of("./prompts"))
format = "xml"
}// generated/acme/blog/WelcomePromptPayload.kt
@Serializable
data class WelcomePayload(
val displayName: String,
val postCount: Long,
val posts: List<PostSummary>,
)
@Serializable
data class PostSummary(val title: String)MetaObjects.Render ships the render engine + verify. MetaObjects.Codegen
ships payload-VO codegen.
using MetaObjects.Render;
var provider = new FilesystemProvider("./prompts");
var payload = new WelcomePayload(
DisplayName: "Ada",
PostCount: 12,
Posts: new[] { new PostSummary("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, origin-aware — see
Output 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 declared template.output,
codegen emits a typed parser that turns an LLM response (raw text) into the
@payloadRef value-object. Reuses the same payload-VO template.prompt does;
no new metadata authoring. See ADR-0010
for the cross-port principle and FR-006
for the design.
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 | — (Jackson ObjectMapper.readValue paired with the SpringPayloadGenerator-emitted record; auto-emit on the roadmap) |
— | (planned: Jackson) |
The throwing API matches the substrate's native deserialization exception
(Zod ZodError, JsonException, ValidationError, SerializationException).
The Result-style API wraps the throwing API and does not throw on validation
failure. All four shipped ports satisfy the same conformance fixture
(template-output-simple).
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 | <TemplateName>.output.ts |
parse<TemplateName> + safeParse<TemplateName> functions |
| C# | <TemplateName>.output.cs |
static class <TemplateName>Parser |
| Python | <template_name>_output_parser.py |
parse_<template_name> function + <TemplateName>Data BaseModel |
| Kotlin | <TemplateShortName>Parser.kt |
object <TemplateShortName>Parser (same package as the payload class) |
The parser file is a companion to (not a replacement for) the existing payload-VO
file — the parser imports the payload class rather than redeclaring it. meta verify
extends to walk template.output nodes the same way it walks template.prompt,
catching payload-VO ↔ parser 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 meta:verify (Maven goal) |
| Kotlin | mvn meta: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@payloadReffixtures/conformance/template-prompt-simple/—template.promptwith@payloadReffixtures/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
Payload origins (origin.*)
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/origin-collection-simple/—origin.collectionfor repeated-row payloadsfixtures/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.valueis the payload's host type - 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
- FR-004 spec: 2026-05-22-fr-004-cross-language-prompt-construction-design.md