Cross-port oracle for the template.output render-helper generator
(render<Name>(payload, provider)). Every port (TS, Java today; C#, Python,
Kotlin to follow) must generate render helpers from this exact metadata + the
exact templates/ here and produce the outputs pinned below — byte for byte.
The provider resolves a 2-layer logical reference group/source to
<templates>/group/source.mustache. So @textRef="pages/welcome" resolves to
templates/pages/welcome.mustache, @subjectRef="emails/welcome.subject"
resolves to templates/emails/welcome.subject.mustache, and so on.
meta.json— the metadata: a payload value-objectWelcome { name: string }plus twotemplate.outputnodes over it:WelcomePage—@kind=document,@format=html,@textRef="pages/welcome",@payloadRef=Welcome. GeneratesrenderWelcomePage(payload, provider): string.WelcomeEmail—@kind=email,@subjectRef="emails/welcome.subject",@htmlBodyRef="emails/welcome.html",@textBodyRef="emails/welcome.txt",@payloadRef=Welcome. GeneratesrenderWelcomeEmail(payload, provider): EmailDocument.
templates/pages/welcome.mustache=Hello {{name}}templates/emails/welcome.subject.mustache=Welcome {{name}}templates/emails/welcome.html.mustache=<p>Hi {{name}}</p>templates/emails/welcome.txt.mustache=Hi {{name}}nested/meta.json— a no-package sub-corpus carrying the nested/array email case (OrderoverCustomer+Item[]); see "Nested + array email" below. It shares the sametemplates/dir.xpkg-collision/— a multi-package sub-corpus (meta.alpha.json+meta.beta.json+meta.app.json) gating FQN-exact nested@objectRefresolution across a cross-package short-name collision (ADR-0041) AND the collision-aware payload-record naming contract (ADR-0044); see "Cross-package short-name collision" below. It also sharestemplates/.templates/emails/order.subject.mustache=Order for {{customer.name}}templates/emails/order.html.mustache=<h1>{{customer.name}}</h1><ul>{{#items}}<li>{{sku}} x{{qty}}</li>{{/items}}</ul>{{> shared/footer}}templates/emails/order.txt.mustache=Order for {{customer.name}}:{{#items}} {{sku}} x{{qty}};{{/items}}templates/shared/footer.mustache=<hr/>Sent by Acme(a partial reused by the order html body).drift/— the build-time drift case (see below).
(None of the .mustache files carry a trailing newline, so the rendered output
has none either.)
| template | kind | call | expected result |
|---|---|---|---|
WelcomePage |
document | renderWelcomePage({name:"Ada"}, p) |
"Hello Ada" |
WelcomeEmail |
renderWelcomeEmail({name:"Ada"}, p) |
EmailDocument below |
EmailDocument for WelcomeEmail:
subject="Welcome Ada"htmlBody="<p>Hi Ada</p>"textBody="Hi Ada"
(name carries no HTML-special characters, so the html-format html body is
identical to its text source after interpolation.)
Renders the SAME WelcomeEmail with a markup/XSS-bearing name, proving the
@format=html part escapes while the @format=text parts stay raw. The engine
owns escaping (Mustache's own HTML-escaping is disabled); the html escaper is
the XML entity set (<→<, >→>, &→&, "→", '→').
subject(text) ="Welcome <b>A & Co</b>"— RAWhtmlBody(html) ="<p>Hi <b>A & Co</b></p>"— ESCAPED (no raw<b>reaches a mail client)textBody(text) ="Hi <b>A & Co</b>"— RAW
nested/meta.json is a no-package sub-corpus: payload VOs
Customer { name: string }, Item { sku: string, qty: int }, and
Order { customer: Customer (field.object @objectRef), items: Item[] (field.object @objectRef, isArray) }, plus an email template OrderEmail
(@payloadRef=Order) over the order.* part-refs. It has no package on
purpose: a bare @objectRef resolves identically across ports only when there is
no package: a bare @objectRef matches the value-object's short name in every
port, so this sub-corpus isolates the nested/array/partial shape from any
package-resolution concern. (The packaged/fully-qualified @objectRef case is
gated separately by xpkg-collision/ below, per ADR-0041.) The shared
templates/emails/order.* + templates/shared/footer mustaches are reused.
Rendered with { customer: { name: "Ada" }, items: [ { sku: "A1", qty: 2 }, { sku: "B2", qty: 1 } ] }:
subject(text) ="Order for Ada"htmlBody(html) ="<h1>Ada</h1><ul><li>A1 x2</li><li>B2 x1</li></ul><hr/>Sent by Acme"(the{{#items}}loop expands both items; the{{> shared/footer}}partial resolves and is appended)textBody(text) ="Order for Ada: A1 x2; B2 x1;"
(No value carries HTML-special characters, so the html body is unescaped after
interpolation; the items/customer payload is plain.) The clean nested template
passes the BUILD-TIME drift gate — and a {{#items}}{{bogus}}{{/items}}
section-context drift (a {{bogus}} not on the Item element type the section
pushes) FAILS codegen with ERR_VAR_NOT_ON_PAYLOAD, proving the gate walks the
nested/section context, not just the root.
A multi-package sub-corpus (loaded as three sources — meta.alpha.json +
meta.beta.json + meta.app.json, mirroring
fixtures/conformance/loader-same-name-distinct-packages) that gates FQN-exact
nested @objectRef resolution (ADR-0041):
acme::alphadeclaresobject.value Note { alphaText: string }.acme::betadeclaresobject.value Note { betaText: string }— a colliding short name in a different package.acme::appdeclares payloadobject.value Digestwith twofield.objectchildren referencing the two Notes by fully-qualified@objectRef(acme::alpha::Note,acme::beta::Note), and adocumenttemplate.outputDigestDoc(@format=html,@textRef="xpkg/digest",@payloadRef="Digest").templates/xpkg/digest.mustache=Alpha={{fromAlpha.alphaText}} Beta={{fromBeta.betaText}}
Each port must resolve a fully-qualified @objectRef exactly on the
package-qualified name — never a bare-tail fallback that binds whichever Note
loads first. A bare-tail resolver collapses BOTH refs to one package's Note, so
one of {{fromAlpha.alphaText}}/{{fromBeta.betaText}} lands on the wrong element
type and the build-time drift gate throws ERR_VAR_NOT_ON_PAYLOAD. FQN-exact
resolution binds each ref to its own package, so the clean template passes.
Rendered with { fromAlpha: { alphaText: "AA" }, fromBeta: { betaText: "BB" } }:
renderDigestDoc(...)="Alpha=AA Beta=BB"
The two colliding VOs share the BARE short name Note. ADR-0044
is the binding contract for what each port's payload emitter must do about that —
this sub-corpus is the executable oracle for it, in addition to gating the
@objectRef resolver above:
-
Payload records/types/interfaces used by a port's conformance runner MUST be GENERATOR-emitted — hand-authoring (or any other manual reconciliation) is PROHIBITED. A hand-written merged record (
{ alphaText?; betaText? }, both optional, both shapes folded into one) is exactly the silently-wrong-adjacent shape ADR-0044 rejects: it erases@requiredand lets a typo against either real shape type-check. Construct the render-time payload instance from the port's REAL generated payload type(s) for this fixture. -
Naming rule: a value-object whose bare short name is unique within the emitted artifact's
@objectRefclosure emits bare, in the port's existing convention; a value-object whose bare short name COLLIDES with another closure member emits under its package-qualified derived name — PascalCase each::-segment of its package, concatenate, append the bare short name, then apply the port's suffix convention. A still-colliding derived name is a hard generator error,ERR_PAYLOAD_NAME_COLLISION. -
Expected emitted names for this fixture, per port (
Digestitself does not collide and stays bare in every port):Port acme::alpha::Noteacme::beta::NoteTypeScript ( interface)AcmeAlphaNoteAcmeBetaNoteC# ( record)AcmeAlphaNoteAcmeBetaNoteJava / Kotlin ( record/data class)AcmeAlphaNotePayloadAcmeBetaNotePayloadPython ( class)AcmeAlphaNotePayloadAcmeBetaNotePayload
The render-output pins above ("Alpha=AA Beta=BB") are UNCHANGED by this contract
— the render engine and the build-time verify field-tree resolve against
metadata, never against record names. Payload-record source stays per-port
idiomatic (Tier-1 codegen); this sub-corpus gates it by compile + construct +
render + name assertions, strengthening the gate rather than weakening it.
Identical to xpkg-collision/ above (same three metadata files, same Digest
payload with colliding Note VOs from acme::alpha and acme::beta), but the
DigestDoc template.output has @format="json" instead of @format="html",
and it additionally declares a DigestPrompt template.prompt whose
@responseRef is that same Digest payload.
The DigestPrompt node is what exercises the extract/output-parser tier.
Since ADR-0052
that tier is INBOUND and keys on a template.prompt declaring @responseRef —
never on template.output, and never on @format ∈ {json,xml}, which was the
old gate. template.output is outbound only and emits no parser, extractor or
response-format fragment at all, so DigestDoc drives the render helper and
nothing else.
The prompt was ADDED beside the output rather than replacing it, deliberately:
this corpus is the outbound render oracle AND the payload tier's optionality
oracle, so converting DigestDoc would have traded two guarantees for a third.
Ports implementing this fixture should expect BOTH nodes, and should emit
inbound artifacts named for DigestPrompt, not DigestDoc.
The generated render helper, collision-aware payload naming, and render output
remain identical — see
Cross-package short-name collision
above for the full contract and expected payload names
(AcmeAlphaNotePayload/AcmeBetaNotePayload for Java/Kotlin/Python;
AcmeAlphaNote/AcmeBetaNote for TS/C#).
drift/meta.json declares the same Welcome VO and a document
template.output WelcomePage whose @textRef="pages/bad" resolves to
drift/templates/pages/bad.mustache = Hi {{missing}}. {{missing}} is not
a field on the Welcome payload VO.
Generating the render helper for this case MUST fail codegen (throw) with the
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.