Skip to content

Latest commit

 

History

History
536 lines (439 loc) · 23.9 KB

File metadata and controls

536 lines (439 loc) · 23.9 KB

Templates and payloads (FR-004)

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:

  1. Drift detection — a renamed field on the source entity breaks the build (Renderer.verify reports it), not silently degrades a prompt.
  2. Snapshot-testability(payload VO, resolved text) → string is a pure function; pin the rendered output as a fixture.
  3. 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.
  4. 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.

Two template subtypes

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

Payload origins

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> countLong; avgDouble; 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.

Authoring

The named example: a WelcomePrompt greets an Author by name and includes their post count + the first 3 post titles.

Canonical JSON

{
  "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
        }
      }
    ]
  }
}

Sigil-free YAML

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

Provider-resolved text

@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 — a Map<String,String>. Test-only.
  • ClasspathResourceProvider — Java/Kotlin: resolves through getResourceAsStream.

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.

The rendered output

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>

What each port generates

TypeScript

@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",
});

Java

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) {}

Kotlin

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)

C#

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"));

Python

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",
))

Output parsing (FR-006)

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.

Cross-port API

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).

Consumer-side usage (Kotlin example)

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.

Generated file naming

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.

Reading the extraction report — and one sharp edge

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 @default satisfies @required. An absent field carrying a @default is filled and classified DEFAULTED — so it is never LOST_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 @default switches off loss detection for that field. And it propagates through extends — adding an innocuous @default to 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() alongside hasLostRequired(). 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#, and defaulted_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).

Drift detection: verify

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

Determinism contract

  • 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.
  • @format drives 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/.

Verified by

The following conformance fixtures gate this feature's behavior across ports:

Template subtypes (metamodel)

Payload origins (origin.*)

Render engine output (fixtures/render-conformance/) — byte-identical Mustache output across ports

Render engine semantics — Mustache-spec behavior pinned cross-port (every port's renderer must emit byte-identical output)

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.

See also