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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ here.**
new command. `examples/brownfield/` holds a runnable project for each, and the recipe ships in
the `metaobjects-authoring` skill.

### Changed

- **A prompt or output template that names a variable not on its payload now raises at render time.**
Behaviour change: a generated render helper used to render `Hi {{nmae}}` as `Hi ` with no error
when the payload declares `name`; it now raises `ERR_VAR_NOT_ON_PAYLOAD (nmae)` (`RenderError` in
Python, `RenderException` in Java, Kotlin and C#, an `Error` in TypeScript). The build-time check
already caught this when the helper was generated; the gap was text edited or served afterwards.
The silent paths were the TypeScript `promptRender()` handle, the Python helper on a Mustache body
(only Jinja was checked), and the Java and Kotlin helpers, whose `Renderer` accepted the payload
field tree and never read it. The C# helper and the TypeScript `render-helper` already raised. All
five ports are held to it by `fixtures/template-output-render-conformance/runtime-drift/`. A call
to `render()` with no declared payload is unchanged: it has nothing to compare against, so it keeps
the engine's default and checks only when you pass `verify` (`verify:` in TypeScript, `verify=` in
Python, `Verify` in C#, the `verify` field of `RenderRequest` in Java and Kotlin). If a template
already named a missing variable, regenerate and fix the name; the generated helper text changes
(TypeScript `prompts.ts` and Python helpers gain a `verify` argument).

### Fixed

- **Python: an array field's `default` generated a string.** A model with
Expand Down
11 changes: 11 additions & 0 deletions docs/features/templates-and-payloads.md
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,17 @@ and checks each one exists on the payload VO. If a template references
body (Python and TypeScript) is checked the same way; see
[Jinja templates](#jinja-templates-python-and-typescript).

**The same check runs at render time in every generated render helper** (the `template.output`
helper and the `template.prompt` handle, in all five ports). The helper carries its payload's
field tree, so text edited or served after codegen that names a variable the payload lacks
(`{{nmae}}` for `name`) raises `ERR_VAR_NOT_ON_PAYLOAD` instead of rendering as an empty
string. Before this, the TypeScript `promptRender()` handle, the Python helper on a Mustache
body, and the Java and Kotlin helpers rendered it silently. Calling the engine's `render()`
directly with no declared payload keeps the engine's default, since there is nothing to compare
against; ask for the check by passing the field tree (`verify` in TypeScript, Java, Kotlin and
C#; `verify=` in Python). Pinned by
[`fixtures/template-output-render-conformance/runtime-drift/`](../../fixtures/template-output-render-conformance/runtime-drift/).

A bare `verify` also checks generated code and the requirement ledger; name the template
check to run it alone. Java and Python skip this check with a note when no template root is
configured; C# does not skip, and a bare run with no `prompts/` or `templates/` directory
Expand Down
2 changes: 1 addition & 1 deletion examples/showcase/.metaobjects/.gen-state/.hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@
"generated/ts/Subscriber.ts": "a78c17f915222ee108290c28468313d77f77b5cff438f485324409302e389a96",
"generated/ts/SubscriberBlurbPayload.ts": "83272130c428ad0cc68190cb63e32b418a0bcfd05345804def9bf22e79b55fcc",
"generated/ts/index.ts": "2c915565fd8711503dbcd0b0139c657bb927c7f0f502d8d2a8b51e026b257694",
"generated/ts/prompts.ts": "67365bc5504dbb19fafbf6c654afa822f7fe6af4e5f24fd246d69657698db606",
"generated/ts/prompts.ts": "9eec821ec8cb6063f98b52b78ae1758cfd22c81a71cbb90b0555dee347a687af",
"generated/ts/requirements/subscriberCanBePausedWithoutErasingHistory.field.enum.test.ts": "a5d2494ce5340673da43f2ee3ef13d7505ee349724e8d85f377a84eafeea50ae"
}
2 changes: 1 addition & 1 deletion examples/showcase/generated/ts/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import { render, type Provider } from "@metaobjectsdev/render";
import type { SubscriberBlurbPayload } from "./SubscriberBlurbPayload";

export function renderSubscriberBlurb(payload: SubscriberBlurbPayload, provider: Provider): string {
return render({ ref: "subscriber/blurb", payload, format: "text", provider });
return render({ ref: "subscriber/blurb", payload, format: "text", provider, verify: [{"name":"name"},{"name":"status"}] });
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ import { render, type Provider } from "@metaobjectsdev/render";
import type { NpcPromptPayload } from "./NpcPromptPayload.js";

export function renderNpcTurn(payload: NpcPromptPayload, provider: Provider): string {
return render({ ref: "npc/turn", payload, format: "xml", provider });
return render({ ref: "npc/turn", payload, format: "xml", provider, verify: [{"name":"name"},{"name":"mood"}] });
}
13 changes: 13 additions & 0 deletions fixtures/template-output-render-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,16 @@ error code `ERR_VAR_NOT_ON_PAYLOAD`, and the message must name the offending
field (`missing`), the template (`WelcomePage`), and the ref (`pages/bad`). This
is the build-time gate that stops a template from drifting away from its payload
contract.

## Runtime drift — `runtime-drift/`

A variable that is not on the payload must RAISE at render time, never render as empty. The
build-time gate cannot see this case: the helper is generated against clean text, and the text is
edited or served by another provider afterwards. `runtime-drift/meta.json` declares the `Welcome`
payload plus a `template.output` (`WelcomePage`) and a `template.prompt` (`WelcomePrompt`) over the
same ref `pages/welcome`. Each port:

1. generates both helpers with the CLEAN `templates/` (the codegen gate passes);
2. renders with `templates/` → `"Hello Ada"`;
3. renders with `runtime-drift/templates/` (`pages/welcome.mustache` = `Hello {{nmae}}`) → the
helper throws, and the message contains `ERR_VAR_NOT_ON_PAYLOAD (nmae)`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"metadata.root": {
"package": "acme::ai",
"children": [
{
"object.value": {
"name": "Welcome",
"children": [
{ "field.string": { "name": "name", "@required": true } }
]
}
},
{
"template.output": {
"name": "WelcomePage",
"@kind": "document",
"@payloadRef": "Welcome",
"@textRef": "pages/welcome",
"@format": "text"
}
},
{
"template.prompt": {
"name": "WelcomePrompt",
"@payloadRef": "Welcome",
"@textRef": "pages/welcome",
"@format": "text"
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello {{nmae}}
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,35 @@ public void Drift_case_FAILS_codegen()
Assert.Contains("missing", ex.Message);
}

// ---------------------------------------------------------------------
// runtime-drift/ → a variable not on the payload RAISES at render time. The helpers are
// generated against the clean templates/; runtime-drift/templates/ serves {{nmae}} for
// name (text edited after codegen, which the build-time gate cannot see). Covers a
// template.output and a template.prompt over the same payload.
// ---------------------------------------------------------------------

[Theory]
[InlineData("WelcomePage", "Acme.Generated.WelcomePageRenderHelper")]
[InlineData("WelcomePrompt", "Acme.Generated.WelcomePromptRenderHelper")]
public void Runtime_drift_variable_not_on_payload_raises(string template, string helperType)
{
var corpus = Corpus();
var root = LoadFromFile(Path.Combine(corpus, "runtime-drift", "meta.json"));
var clean = Path.Combine(corpus, "templates");
var drifted = Path.Combine(corpus, "runtime-drift", "templates");

var file = Assert.Single(new RenderHelperGenerator(clean).Generate(Ctx(root)), f => f.Path == template + ".render.cs");
var asm = CompileToAssembly([file.Content, .. GeneratedValueObjects.Sources(root)]);

var render = asm.GetType(helperType)!.GetMethod("Render")!;
var payload = MakeWelcome(asm, "Ada");
Assert.Equal("Hello Ada", (string)render.Invoke(null, [payload, new FilesystemProvider(clean)])!);

var ex = Assert.Throws<System.Reflection.TargetInvocationException>(
() => render.Invoke(null, [payload, new FilesystemProvider(drifted)]));
Assert.Contains("ERR_VAR_NOT_ON_PAYLOAD (nmae)", ex.InnerException!.Message);
}

// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion server/csharp/MetaObjects.Render/Renderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
// owned (the format escaper is installed as Stubble's encoding function, the
// equivalent of overriding mustache.js's Mustache.escape). `{{{x}}}` bypasses it.
//
// The render-time `verify` guard (FR-004 Plan #3) is intentionally not wired yet.
// The render-time `verify` guard (FR-004 Plan #3) is wired: a request carrying the payload field
// tree verifies the resolved body before it renders, and a mismatch throws RenderException.

using Stubble.Core.Builders;
using Stubble.Core.Settings;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,40 @@ class KotlinRenderHelperConformanceTest {
}
}

// -------------------------------------------------------------------------
// runtime-drift/ → a variable not on the payload RAISES at render time. The helpers are
// generated against the clean templates/; runtime-drift/templates/ serves {{nmae}} for
// name (text edited after codegen, which the build-time gate cannot see). Covers a
// template.output and a template.prompt over the same payload.
// -------------------------------------------------------------------------
@Test fun `runtime drift raises for output and prompt helpers`() {
val outDir = Files.createTempDirectory("krhc-rdrift-")
val clean = corpus.resolve("templates")
val drifted = corpus.resolve("runtime-drift").resolve("templates")
try {
generate(corpus.resolve("runtime-drift").resolve("meta.json"), outDir, clean)
val result = compile(outDir)
assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages)
val cl = result.classLoader

val payloadClass = cl.loadClass("acme.ai.Welcome")
val payload = payloadClass.getDeclaredConstructor(String::class.java).newInstance("Ada")
val providerClass = cl.loadClass("com.metaobjects.render.Provider")
for (name in listOf("WelcomePageRenderHelper", "WelcomePromptRenderHelper")) {
val helperClass = cl.loadClass("acme.ai.prompts.$name")
val instance = helperClass.getDeclaredField("INSTANCE").get(null)
val render = helperClass.getDeclaredMethod("render", payloadClass, providerClass)
assertEquals("Hello Ada", render.invoke(instance, payload, fsProvider(cl, clean)), name)
val ex = kotlin.test.assertFailsWith<java.lang.reflect.InvocationTargetException>(name) {
render.invoke(instance, payload, fsProvider(cl, drifted))
}
assertTrue(ex.cause?.message?.contains("ERR_VAR_NOT_ON_PAYLOAD (nmae)") == true, "$name: ${ex.cause?.message}")
}
} finally {
outDir.toFile().deleteRecursively()
}
}

// -------------------------------------------------------------------------
// email → EmailDocument
// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,44 @@ public void driftCaseFailsCodegen() throws Exception {
// Helpers
// -------------------------------------------------------------------------

// -------------------------------------------------------------------------
// runtime-drift/ -> a variable not on the payload RAISES at render time. The helpers are
// generated against the clean templates/; runtime-drift/templates/ serves {{nmae}} for
// name (text edited after codegen, which the build-time gate cannot see). Covers a
// template.output and a template.prompt over the same payload.
// -------------------------------------------------------------------------
@Test
public void runtimeDriftRaisesForOutputAndPromptHelpers() throws Exception {
assertNotNull("corpus must be reachable", CORPUS);
Path clean = CORPUS.resolve("templates");
Path drifted = CORPUS.resolve("runtime-drift").resolve("templates");
MetaDataLoader loader = loadCorpus(CORPUS.resolve("runtime-drift").resolve("meta.json"), "rdrift");

Path gen = tmp.newFolder("rdrift-gen").toPath();
generate(loader, gen, clean);

Path classes = compile(collectSources(gen));
try (URLClassLoader cl = new URLClassLoader(
new URL[]{ classes.toUri().toURL() }, getClass().getClassLoader())) {
Class<?> payloadClass = cl.loadClass("acme.ai.Welcome");
Object payload = payloadClass.getConstructor(String.class).newInstance("Ada");
Class<?> providerClass = Class.forName("com.metaobjects.render.Provider");

for (String helper : new String[]{ "WelcomePageRenderHelper", "WelcomePromptRenderHelper" }) {
Method render = cl.loadClass("acme.ai.prompts." + helper)
.getMethod("render", payloadClass, providerClass);
assertEquals(helper, "Hello Ada", render.invoke(null, payload, newFilesystemProvider(clean)));
try {
Object out = render.invoke(null, payload, newFilesystemProvider(drifted));
fail(helper + " rendered a variable not on the payload instead of raising: \"" + out + "\"");
} catch (java.lang.reflect.InvocationTargetException e) {
String msg = String.valueOf(e.getCause().getMessage());
assertTrue(helper + ": " + msg, msg.contains("ERR_VAR_NOT_ON_PAYLOAD (nmae)"));
}
}
}
}

private MetaDataLoader loadCorpus(Path metaJson, String baseName) throws Exception {
assertTrue("corpus meta.json must exist: " + metaJson, Files.exists(metaJson));
Path ws = tmp.newFolder("ws-" + baseName).toPath();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
/**
* Mustache-driven render pipeline for the {@code template.*} metatype (FR-004).
*
* <p>Pipeline: resolve template text (inline or via provider) → pre-expand
* <p>Pipeline: resolve template text (inline or via provider) → verify it against the
* request's payload field tree when one is given → pre-expand
* partials by recursive inlining (cycle-guarded, MAX_DEPTH=32) →
* Mustache.java compile + execute (HTML escaping DISABLED on the factory
* because the {@link Escapers} layer owns escaping) → apply format-keyed
Expand All @@ -41,6 +42,22 @@ public String render(RenderRequest req) {
? req.template()
: resolveOrThrow(req.provider(), req.ref());

// Drift guard: when the request carries the payload field tree, the RESOLVED body is
// verified against it before it renders, so `{{nmae}}` for `name` raises instead of
// rendering empty. A warning-level result (a declared required slot the body never
// uses) is not drift. Matches TS render.ts and C# Renderer.Render.
if (req.verify() != null) {
List<VerifyError> drift = Verify.check(body, req.verify(), new VerifyOptions(req.provider(), null, null))
.stream()
.filter(e -> !Verify.ERR_REQUIRED_SLOT_UNUSED.equals(e.code()))
.toList();
if (!drift.isEmpty()) {
throw new RenderException("render verify failed: " + drift.stream()
.map(e -> e.code() + " (" + e.path() + ")")
.collect(java.util.stream.Collectors.joining(", ")));
}
}

String expanded = preExpandPartials(body, req.provider(), new ArrayDeque<>());

// Per-variable escaping via the Mustache encode() hook — equivalent to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,11 @@
generator declares no payload type. A plain ``dict`` still renders — the annotation is for
the caller, and the render engine reads a model and a mapping alike.

Python divergence vs TS / Java / C#: Python's ``RenderRequest`` has NO ``verify``
field — the Python render engine does not run a runtime drift pass — so the emitted
helper does NOT pass a runtime ``verify`` field-tree. The BUILD-TIME gate that runs
here is the only, and a complete, drift guarantee: codegen cannot succeed unless
every referenced mustache verifies clean against the payload VO. The field-tree walk
is still performed (it is what ``verify`` checks against); it is simply not baked into
the emitted call. The emitted helper shape is otherwise identical to the other ports.
The emitted call bakes in the payload field tree as ``verify=``, so the render engine
checks the template it resolves at RUN time against the payload too (a ``RenderError``
carrying ``ERR_VAR_NOT_ON_PAYLOAD``), the same as every other port. That covers text edited
or served after codegen, which the BUILD-TIME gate cannot see; without it ``{{nmae}}`` for
``name`` would render as an empty string. Both engines are covered, Mustache and Jinja.

Drift message style matches the other ports exactly:
``render-helper drift: template "<N>" ref "<r>" — <CODE>: {{<f>}} not on payload VO``
Expand Down Expand Up @@ -479,20 +477,17 @@ def render_helper_generator(


def _jinja_imports(engine: str) -> list[str]:
"""The import a helper needs when it renders a Jinja body (ADR-0061); none for Mustache,
so a Mustache helper is byte-identical to what it was before Jinja existed."""
if engine != TEMPLATE_ENGINE_JINJA:
return []
"""The import every helper needs: the ``PayloadField`` type its baked-in ``verify=`` tree is
written with. (A Mustache helper used to need none, before it passed ``verify``.)"""
return ["from metaobjects.render.verify import PayloadField"]


def _jinja_request_lines(engine: str, fields: list[PayloadField], pad: str) -> list[str]:
"""For a Jinja body: name the engine (so a runtime provider that cannot tell, such as an
in-memory one, still renders it as Jinja) and run the payload check again at render
time against the field tree the build gate used."""
if engine != TEMPLATE_ENGINE_JINJA:
return []
return [
f"{pad}engine={_py_str(TEMPLATE_ENGINE_JINJA)},",
f"{pad}verify={_field_tree_literal(fields)},",
]
"""Run the payload check again at render time against the field tree the build gate used,
for either engine. A Jinja body also names its engine (ADR-0061), so a runtime provider that
cannot tell, such as an in-memory one, still renders it as Jinja."""
lines = []
if engine == TEMPLATE_ENGINE_JINJA:
lines.append(f"{pad}engine={_py_str(TEMPLATE_ENGINE_JINJA)},")
lines.append(f"{pad}verify={_field_tree_literal(fields)},")
return lines
7 changes: 3 additions & 4 deletions server/python/tests/codegen/test_render_helper_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,15 +555,14 @@ def test_jinja_not_static_fails_codegen(tmp_path) -> None:
raise AssertionError("expected ERR_TEMPLATE_NOT_STATIC to fail codegen")


def test_mustache_helper_has_no_engine_or_runtime_tree(tmp_path) -> None:
def test_mustache_helper_names_no_engine_but_bakes_in_the_runtime_tree(tmp_path) -> None:
_write_mustache(tmp_path, "pages/welcome", "Hi {{name}}")
payload = _payload_vo("Welcome", [_field("name")])
tmpl = _document_template("WelcomePage", "Welcome", text_ref="pages/welcome")
root = _root([payload, tmpl])
src = RenderHelperGenerator(str(tmp_path)).generate(_ctx(root))[0].content
assert "engine=" not in src
assert "verify=" not in src
assert "PayloadField" not in src
assert "engine=" not in src # a Mustache body needs no engine named
assert 'verify=[PayloadField("name")]' in src # but it is still checked at render time


def test_email_parts_may_mix_engines(tmp_path, monkeypatch) -> None:
Expand Down
Loading
Loading