ADR-0003: The template subset

Copy Markdown View Source

Status: accepted

Context

A content document carries authored prose that is not fixed text: a node's label greets the visitor by the name they just gave, a summary line reads back what they chose. The public fixture priv/fixtures/signup_screens.json in statifier_examples (read at 9d288c9) already writes it that way - four of its strings interpolate responses.first_name and responses.email in Liquid output tags - so the question is not whether documents carry templates but which templates they may carry.

Liquid is the language, and solid (hex, 1.3.4 as this repository's mix.lock resolves it) is the parser. Liquid as a whole is larger than this runtime should promise. Three forces narrow it.

Renderers are the host's. ADR-0001 fixed that resolution happens in this package and rendering does not, so what a template produces crosses a boundary into code this package does not own and cannot inspect. What crosses has to be safe to hand to any renderer - a browser, a native view, a PDF, an email body - without that renderer knowing which of them it is.

A second language has to reproduce the corpus. The cases are authored here and emitted into riddler_spec, and a non-Elixir runtime is held to them. Every construct this package accepts is a construct that runtime must implement identically, and every construct it refuses is one that runtime need not build at all. A large accepted surface is a large porting bill, paid by whoever ports.

An editor validates at authoring time. A host authoring a content document wants to be told that a template is wrong while the author is looking at it, not when a visitor reaches the screen. That is only possible if the answer is knowable before any context exists.

Decision

The subset is an allowlist, not a denylist. What this package accepts is enumerated; everything else is refused, including constructs Liquid or solid may add later. A denylist would silently admit whatever a dependency upgrade introduced, and a conformance corpus cannot be written against a surface that grows on its own.

The allowlist is: output, a fixed set of tags, and a fixed set of filters. Output tags carry a variable path and a chain of filters. The admitted tags are if, elsif, else and unless; case and when; for, including its limit and offset arguments; assign; capture; comment; and raw. The admitted filters are the standard string, number, array and date filters, together with default, less the escaping filters the output-mode rule below refuses. Adding to this list is a change to this record.

The exclusion list is: include, render, increment, decrement, cycle, tablerow, liquid and echo, and every vendor-specific construct. include and render reach for a file that a content document does not have and a second runtime would have to invent a filesystem to serve. increment and decrement and cycle carry state between renders, which makes a rendered document depend on how many times it was rendered. tablerow emits markup, which the output-mode rule below forbids in v1's text mode. liquid and echo are alternate spellings for constructs already in the allowlist and would double every corpus case that uses them. A vendor construct is by definition one no second runtime can be held to.

Anything outside the allowlist fails at compile, never at render. A template is compiled once, without a context, and a template holding a refused construct is refused there - so the editor's authoring-time answer and the runtime's answer are the same answer, reached by the same code. A construct that parsed cleanly and then failed when a visitor arrived would be a defect in this rule, not an accepted behavior.

Compilation reports one finding per refused construct, not the first. An author fixing a template learns everything wrong with it in one pass.

The output mode is declared by the content kind, not by the template. v1 has one mode, text: a template produces a string that means exactly the characters in it, this package emits no markup, and it escapes nothing, because escaping is the act of something that knows what it is rendering into and a text mode is not rendering into anything. The absence of autoescape is therefore not a hole in this record: a host that renders text into HTML escapes what it is given, exactly as it escapes any other untrusted string. A markup kind - an email's HTML part, an SVG - declares an escaping mode in its own record, and in such a mode an interpolated value is escaped by default. What never changes is that the author does not choose: escape, escape_once, newline_to_br and strip_html stay refused in every mode, because escaping is the mode's job and a template that escapes by hand is a template that double-escapes under a mode that escapes for it.

There are two render modes, lenient and strict; they differ only in what a missing thing does, and they are the caller's choice. In lenient mode a missing variable renders as the empty string and the render returns the list of what was missing. In strict mode a missing variable or a missing filter is an error and the render returns it. The two modes agree on every template where nothing is missing: mode is not a second dialect. Each content kind's record names its default: screens render lenient at runtime, because a visitor should see a screen rather than an error page when an optional field has not been filled, and strict is for preview and for the corpus, because an author and a conformance case both need to be told. A kind for which a missing variable is a defect rather than a blank - an email, a feature flag - will name strict, and naming it is that kind's record's job, not this one's.

default is the authored answer for an optional field. A template whose variable may be absent says so with default, and then it is not missing in either mode. Lenient mode's empty string is the fallback for what an author did not anticipate, not the way to write an optional field.

A host-registered filter contract is part of this version and ships empty. A host may need a filter this package does not carry - a currency format, a domain-specific abbreviation. The shape is fixed now: a registered filter is a name plus one implementation per runtime, declared to this package so that compilation accepts the name, with the second runtime obliged to supply its own implementation of the same name. No filter is registered in this version, and the registry ships empty; designing the seam now is what keeps a later host need from becoming a change to the allowlist rule.

The exact filter list is delegated to the code half's tests. Riddler.Template and its test file are the enumerating surface: the tests name every admitted filter and every refused construct, and the corpus emitted from them is what a second runtime is held to. This record asserts what the categories are and does not enumerate their members, because a list in prose and a list in code drift apart and only one of them is executable.

Consequences

The code half builds Riddler.Template.compile/1, which takes template source and returns either a compiled template or the findings that refuse it, one per refused construct, without a context; and Riddler.Template.render/3, which takes a compiled template, a context and a mode, and returns the rendered text together with what was missing in lenient mode or the error in strict mode. Its tests enumerate the filters.

The corpus carries at least one refusal case per excluded construct, and every render case is stated in both modes, so a second runtime cannot pass by implementing only the forgiving half.

A second runtime implements exactly the allowlist. It may use any Liquid library it likes, provided that library's extra constructs are refused at compile: passing the corpus means refusing what the corpus says is refused, not only rendering what it says renders.

An editor can validate a template with nothing but the template, which is what makes authoring-time validation possible at all.

Nothing here decides how a document declares which of its strings are templates, what a node is, or when a template is evaluated during resolution. That is ADR-0002, the screen document. Nothing here decides transport, authentication, streaming, the identity of a visitor's execution, or the editor; ADR-0001 left those open and this record leaves them open. Nothing here decides a markup kind's escaping mode either: the rule above says such a mode is declared by the kind and escapes by default, and which kind declares which mode is that kind's record's.

The vocabulary of ADR-0001 holds: a screen document declares writes, a control names an outcome, what a visitor submits is responses, and the host-supplied root is context, which every kind carries. A template path reads from those roots.

Typespecs and worked example

The types are the code half's. This record names no struct and no typespec: what compile/1 returns, what a finding is made of, and how a mode is spelled are Riddler.Template's to declare, and a typespec written here would be a second source of truth for something the compiler already checks.

One sentence needs illustrating: that a refusal is reached before any context exists. Take the signup wizard the fixture authors. An allowed template on the confirmation screen is

We will send a verification link to {{ responses.email | default: "your inbox" }}.

It compiles: an output tag, a variable path under responses, and default from the allowlist. It renders the same text in both modes, because default means nothing is missing either way.

A refused template on the same screen is one that pulls a shared fragment in - an author's attempt to reuse a footer by naming another file with include. Compilation refuses it with one finding, which names the construct include, says it is not in the allowlist, and points at where in the source it appeared. That answer is available to the editor the moment the author stops typing: no visitor, no responses, no context was needed to reach it.


Recorded 2026-09-14, campaign RF049, bead rd-61p. Rewritten in place while still proposed on 2026-09-15, campaign RF049, bead rd-9wd: the text-only rule became the output-mode rule, which each content kind declares, and each kind's record now names its own render-mode default.

Accepted 2026-09-15, campaign RF049, bead rd-x59, after a claim-by-claim reading against main (riddler 0.1.0, published). The code half that built what this record decides is Riddler.Template.compile/1 and render/3 behind the allowlist (PR 5), with the templates.render corpus (PR 9); the output-mode rule and the per-kind render-mode default arrived with the content-runtime reframe (PR 14). The paragraph above, recording a rewrite made before this record was accepted, describes the state this Note ends: the record is accepted from this date, and a further change to what it decides is an amendment, not an edit in place.

The reading found no claim this record makes that the code does not hold to, and both of the Consequences claims about the corpus check out: there is a refusal case for every excluded construct, and every render case is stated in both modes except the missing-variable pair, which is the one case the modes are defined to answer differently. One question this record leaves open is carried as a note by addition: strict mode is stated for a missing variable and a missing filter, and a variable used only as an if or unless condition is neither plainly one nor plainly outside the rule (rd-1jj).

Note, 2026-09-17, campaign RF051, bead rd-1jj. The question the paragraph above carries by addition - whether a variable that appears only as an if or unless condition is a missing variable under strict mode - is decided here.

Strict mode covers output and iteration positions. A condition operand is a truthiness test: a variable used only in a condition is not missing when the root does not carry it, it is false. A template reading {% if responses.newsletter %}...{% endif %} against a root without responses.newsletter renders the branch that holds, in strict mode exactly as in lenient mode: no error, and nothing named in either mode's missing list.

The rule is positional, not expressional. The condition of an if, an elsif or an unless is one position, whatever expression stands in it: a bare path, a comparison such as {% if responses.plan == "business" %}, a chain joined by and or or. A path the root does not carry, anywhere inside such a condition, is false there and the condition is evaluated with it. unless is a condition position on this rule exactly as if is; the tag's name does not change what its condition is. Positions this rule does not reach keep what they do today: a case subject and an assign or capture right-hand side read a value rather than test one, and a missing variable in them is reported under strict mode.

Two things decide it this way. The first is what a visitor should see. A conditional block is how an author asks whether an optional field was filled, and when it was not, the right outcome is that the block does not render - a screen without its optional block, not a screen replaced by an error. Strict mode exists to tell an author and a conformance case that a template asked for a value that was not there; a condition did not ask for a value, it asked a question, and an absent value makes that question false. The second is the porting bill. Stated by position, the rule is one a second runtime implements by looking at where the path appears in its own parse tree. Stated by what a particular engine happens to report, it would be a rule no two runtimes could agree on.

What the code does today, read at 27faac3: Riddler.Template.render/3 in lib/riddler/template.ex passes strict_variables: true to solid (1.3.4, as this repository's mix.lock resolves it) in both modes and sorts what comes back in its private missing/2, so mode decides only whether a missing variable is an error or a list beside the text. That engine reports a missing for operand, case subject and assign right-hand side, and does not report a missing if or elsif condition. The if half therefore already behaves as decided above, and the asymmetry between a loop operand and a condition operand is the observation this note settles rather than a behavior it introduces.

unless is the one place the code does not yet match, and that is a defect in the code half rather than a second semantics. At 1.3.4 the engine evaluates an if and an unless condition through the same call but keeps that evaluation's recorded errors only when the branch it renders is the branch that call threw, so {% unless b %}A{% endunless %} against a root without b renders A and also reports b, where {% if b %}A{% endif %} reports nothing. The difference is the engine's error bookkeeping, not the meaning of the two tags, and this record decides the meaning. Under this note {% unless b %}A{% endunless %} renders A with nothing missing in either mode, and bringing the code to that is the code half's work, not a change to what is decided here.

The conformance corpus carries the pair (rd-alj): a variable used only as an if condition and one used only as an unless condition, each rendered against a root that does not carry it, each stated in both modes, each expecting an empty missing list, a render that succeeds, and the text of the branch that holds. A second runtime that reports either of them fails the corpus.


Noted 2026-09-18, campaign RF055, bead rd-tmv. One note by addition, read against main at 90f5930. Nothing above is changed.

A when operand is a read position, and the list of positions the rule above does not reach was not exhaustive. The note above states its rule by position and then names both sides of it. On the one side, "The rule is positional, not expressional. The condition of an if, an elsif or an unless is one position, whatever expression stands in it". On the other, "Positions this rule does not reach keep what they do today: a case subject and an assign or capture right-hand side read a value rather than test one, and a missing variable in them is reported under strict mode." A when operand is named in neither list. It belongs with the second: a when operand is read and compared against the subject the case tag carries, and it reports its missing variable under strict mode exactly as that subject does.

What the code does, read at 90f5930. The test named "a when operand still reports the missing variable" (test/riddler/template_test.exs) pins both modes: {% case "yes" %}{% when responses.newsletter %}A{% endcase %} rendered through Riddler.Template.render/3 against a root that does not carry responses.newsletter answers {:ok, "", ["responses.newsletter"]} in lenient mode and {:error, ["responses.newsletter"]} in strict mode. That is the answer a read position gives, and it is the answer the case subject beside it gives: the test named "a case subject still reports the missing variable", in the same file and read at the same commit, pins the subject in the same two modes.

No corpus case has a when operand as its missing variable, so nothing here is complete. corpus/templates/render.json, read at 90f5930, carries the case named "A case subject the root does not carry is still reported in strict mode: a subject reads a value rather than testing one", which states the subject in strict mode alone, and it carries no case in which a when operand is the missing variable. A second runtime is held to what this entry records only once the corpus carries it; today just this package's own test does. Whether the corpus should carry the case is not decided here.

Why a note and not an amendment. The rule above already answers for every position it does not reach - they "keep what they do today" - and this entry answers a when operand the same way that clause does; what was missing is the enumeration naming it. Recording that the rule does not reach one, and that the position keeps what it does today, takes nothing away: no template that renders stops rendering, no missing list changes, and no refusal is added. What it fills is an enumeration that reads as exhaustive and is not, which is where a reader takes an omission for a decision. Whether a later amendment should bring a when operand under the condition rule - it is a test of a kind, compared against a subject rather than output - is a question this entry leaves open rather than settles.


Noted 2026-09-18, campaign RF058, bead rd-6h8. One note by addition, read against main at a4f731d. Nothing above is changed.

The condition rule costs a walk of the whole parse tree at render, and for a screen that walk is on the ordinary path. The note of 2026-09-17 above decides that a variable used only in a condition is missing in neither mode. The code holds to it by computing, at render, the condition positions to leave out of the missing list: condition_positions/1, private to lib/riddler/template.ex, collects them by walking the whole parse tree of the compiled template. Riddler.Template.render/3 runs that walk on every render in which the engine reports an undefined variable or an undefined filter, and does not run it on a render in which it reports neither. A screen renders lenient at runtime, as the Decision above names, and under lenient mode a missing optional variable is the expected case rather than an exceptional one, so for a screen the walk is not a rare-path cost.

What it costs, measured against main at 80977f3. Four templates in the signup-wizard domain - one output tag and no conditional; six output tags and two conditionals; 42 output tags and twelve conditionals, nested; 66 output tags and twenty conditionals, nested - were each compiled once and rendered through Riddler.Template.render/3 in lenient mode, against a root carrying every variable, where the walk does not run, and a root missing optional variables, where it does: the median of five repeats of 2,000 renders, on one arm64 machine under Elixir 1.18.3 and OTP 27. The difference between the two roots is an upper bound on the walk rather than a measurement of it, because it also carries the engine's own error bookkeeping and the filtering and de-duplicating of its error list. Across the four templates that bound was 0.458, 3.630, 23.466 and 39.414 microseconds, between 52.9 and 62.8 percent of the lenient render with missing variables. It is fixed per render rather than per missing variable: a root missing one variable cost the same, within noise, as a root missing three. On the 42-output template the bound is about 23 microseconds a render, against about 264 microseconds to compile the same template once. The measuring script was not committed, and nothing here is a claim about end-to-end request cost or about any workload not measured.

The design stands: the condition positions are not computed at compile. About 23 microseconds a render on a realistic screen is not a cost a host will see, and nothing measured is slow. Computing the positions once at compile would store them on Riddler.Template.Compiled (lib/riddler/template/compiled.ex), a struct whose every field is an enforced key and which a host keeps, caches and hands back to render/3, as its moduledoc says: a new field there is a change to a public surface hosts hold, not a private optimisation. The question is reopened when a host reports render latency, and not before.

Why a note and not an amendment. Nothing here changes an answer this record gave: no template renders differently, no missing list changes, and no refusal is added. The entry records what the rule the note of 2026-09-17 decided costs at render and why that cost is kept where it is. Storing the positions at compile would change a public struct, and it is recorded when it is taken.


Amendment, 2026-09-18: whether a template holds a liquid tag is the parser's answer

Status: proposed

Recorded 2026-09-18, campaign RF058, bead rd-zar. The Decision excludes liquid as an alternate spelling for constructs already in the allowlist. The parse tree does not record that a liquid tag was there - the tags written inside one arrive as ordinary nodes - so refusing the spelling means finding it somewhere other than the tree, and this record never said where. The code half of 0.2.0 found it by reading the source with patterns of its own, and a pattern is a second reading of the template that can disagree with the parser's. This amendment decides whose reading counts. Cites to the code this amendment replaces are read at a4f731d; the code this amendment describes is added by this entry's own commit and is citable at no earlier SHA.

What the record now decides

A template holds a liquid tag exactly where the parser, reading the template as written, reads one, and it is refused there. The check that refuses the spelling asks the parser where a liquid tag begins; it does not re-read the source with a pattern. Characters the parser reads as anything other than a tag - text, a string, the body of a raw or comment block, a token a tag reads and discards - are not a liquid tag and refuse nothing. A liquid tag the parser reads is refused whatever characters stand around it. The finding is the one the Decision already gives an excluded construct: template.tag_not_allowed, naming liquid, at the place the tag begins.

Where a raw or comment block begins and ends is the parser's answer too. At solid 1.3.4, as this repository's mix.lock resolves it, a closer carrying trailing text the parser discards - {% endraw note %}, {% endcomment note %} - ends its block, and a comment opener carrying trailing text opens one. What stands inside such a block is text, a liquid tag's characters included.

What was wrong, stated exactly. The tags inside a liquid tag are nodes in the parse tree, and the allowlist walk refuses every one the subset refuses whether or not the spelling is found. What 0.2.0 admitted, in the templates below, was an admitted construct written in a spelling this record excludes: a conformance gap, a template this runtime admitted that a runtime implementing the subset need not accept. It was not a construct outside the allowlist reaching a render.

Why an amendment and not a note

Because it refuses templates this version admitted, which docs/adr/README.md names as sufficient for an amendment. At a4f731d each of these compiled with its liquid tag, holding an assign, rendered: a raw marker written in a string that an endif, an else, an endfor, a comment opener or an endcomment discards, or inside a comment whose opener carried trailing text, followed later by a liquid tag and a real raw block; and a liquid tag after a raw or comment block whose closer carried trailing text, followed by a later block of the same kind. The source patterns in Riddler.Template (the private liquid_refusals/2, read at a4f731d) took the first marker for an opener and paired it with a later closer, and blanked the liquid tag between them. It also admits templates this version refused: a liquid tag's characters inside a raw block whose closer carried trailing text, or inside a comment block whose opener or closer did, were refused at a4f731d as a liquid tag. The Decision's exclusion list is unchanged; which templates it reaches is not.

What the code does

The check puts each place the characters liquid occur to the parser, as the author's source up to that place with a name no tag answers to in its stead, and refuses where the parser's first refusal of that source is that name met as a tag (Riddler.Template, the private liquid_refusals/1). A generated test holds the check to the parser in both directions, with an assign inside every liquid body so that the allowlist walk cannot refuse the template on its own (test/riddler/template_liquid_test.exs, the describe block "generated agreement with the parser").

What is unchanged

The allowlist, the exclusion list, refusal at compile, and one finding per refused construct. The characters of an excluded tag printed from a string literal or a bracket subscript are text, as the corpus cases "A refused tag's characters inside a string literal render as those characters in both modes: the exemption is the parse tree's, not the source's" and "A refused tag's characters inside a bracket subscript are a key, not a tag: the subscript reads the value under that key in both modes" state.

Consequences

The corpus gains a refusal case for each spelling above that 0.2.0 admitted, each named "liquid is refused where a raw marker stands ..." or "liquid is refused after a ... block whose closer carries ...", and a text case for each it refused, each named "A liquid tag's characters inside a ... block whose ... carries ..." (corpus/templates/render.json).

Those cases hold a second runtime to the parser's reading of tokens it discards, and both kinds do: a runtime whose Liquid library refuses {% endif "x" %} or {% endraw note %} outright answers a parse failure where a refusal case states the liquid refusal, and where a text case states a render. Whether the subset should admit a tag token carrying text the parser discards at all is a question this amendment does not decide.


Noted 2026-09-18, campaign RF058, bead rd-d5c. One note by addition, read against main at 893425d; the code and the corpus it cites are read at d24d896, the code commit of the request that carries it. Nothing above is changed.

Which code a closing tag with no block open answers is part of the contract, stated by what the source holds. A branch keyword - elsif, else or when - or a closing tag - endif, endunless, endcase, endfor, endcapture, endcomment or endraw - written where no block it can belong to is open, in a template that refuses nothing else, is refused as template.tag_not_allowed naming that keyword, and not as template.parse_error. Beside another refusal it is not always reported; that shape is left open below. A runtime reaches that answer by knowing which blocks are open where the keyword stands, whatever its Liquid library reports for it. The corpus case "A closing tag written where no block of its tag is open is refused as that tag, not as a parse error" (corpus/templates/render.json) holds a second runtime to it.

A closing tag that ends a block the author opened is not refused when what the block holds is refused for something other than a branch or closing keyword it cannot hold. A refused tag or filter inside an admitted block, or a source inside one that does not parse, is refused on its own and the block's closer is not reported. The corpus case "A refused tag inside an if block is the one finding: the closing tag that ends the block is not refused", in the same file, holds a second runtime to it. A block that holds a misplaced keyword is outside this rule and is left open below.

How this package reaches those answers is not the rule. It reads the reference parser's reason for a tag it did not expect in two places: the @unexpected_tag pattern parse_refusal/1 matches, and the @probe_reason string probe/1 compares (both in lib/riddler/template.ex). A solid release that rewords that reason turns this package's corpus run red rather than changing an answer unnoticed; that safeguard is this package's own and binds no other runtime. The second rule is reached by drop_derivative/1, which drops the refusal of a keyword in @block_keywords whenever the parser also refused something that is not one, and drops nothing when every refusal it was given is such a keyword.

What this note does not decide: a closing tag with no block open beside another refusal. drop_derivative/1 cannot tell the closer of a block the author opened from a closer with no block open, and a refused construct the allowlist walk would find in the parse tree is not reached when the parser refuses the source (compile/1). So {% include 'footer' %}{% endif %} answers the include finding alone, and {% render 'footer' %}{% endif %} answers the endif finding alone. The corpus states neither answer, and a second runtime is not held to either. Whether the closer is reported beside another refusal is left open here. That render goes unreported in the second template falls short of the Decision above, which reports one finding per refused construct, and bringing the code to it is the code half's work, not a change to what is decided here.

What this note does not decide either: a block that holds a branch or closing keyword it cannot hold. Where the block holds nothing else that is refused, the misplaced keyword is refused, and so are the block's own keywords and closer, because drop_derivative/1 has no refusal beside them that is not a keyword. Where it also holds a refusal the parser reports, such as include, only that refusal is reported; where the other refusal is one the allowlist walk would find in the parse tree, such as render or a refused filter, it is not reached, as above, and the keywords are reported as if it were absent. {% for x in responses.a %}{% endif %}{% endfor %} answers endif and endfor; {% if responses.a %}{% endfor %}{% endif %} answers endfor and endif; and {% case responses.a %}{% when 1 %}{% elsif b %}{% endcase %} answers when, elsif and endcase, refusing a when whose case is open. The corpus states none of these answers, and a second runtime is not held to them. Whether a block's own keywords and closer are reported beside a misplaced keyword is left open here.

Why a note and not an amendment. No answer changes: every template compiles or is refused as it was, with the same findings, and no refusal is added. The Decision refuses everything outside the allowlist and names no code; this entry records which code a closer with no block open takes when it stands alone, and that the closer of an opened block takes none when the block's refused content is not a misplaced keyword, which is what the code already answered, and it leaves open the answer beside another refusal and inside a block holding a misplaced keyword. The corpus case it adds states an answer this package already gave.