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.