All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Entries for unreleased work are not written here directly. Each issue drops a
fragment in changelog.d/; the fragments are assembled
into a version section at release. See that README for the format and for when a
change warrants an entry at all.
[0.4.0] 2026-10-20
A minor release for questions that ask a visitor to choose, for presenting a
Journey as data, and for the template subset's refusals. A screen document may
carry choice_question and boolean_question nodes, and
Riddler.Screens.validate_screen/3 and /4 check a response to either.
Riddler.Journey presents the status a host passes as a screen, waiting or
done, and replaces a root's context whole. The template subset reports the
refusals a tag the parser does not know used to hide, refuses a misplaced
keyword as itself, and refuses a tag carrying text it throws away. A
schema_version written as 1.0 reads as version 1, a metadata written as
null is not a document, and the response.undecidable message says what the
evaluator answered.
Added
- A screen document may carry a
choice_questionnode, which asks a visitor to pick one option or several (selectof"one"or"many") fromanswer_options, each option avaluein the key shape and alabeltemplate, with an optionalstylehint,required, andmin_selectionsandmax_selectionsunder"many"; a document using it was refuseddocument.unknown_typebefore, and no document admitted before changes its answer. - A screen document may carry a
boolean_questionnode, a lone checkbox or toggle with alabeltemplate and an optionalrequired; a document using it was refuseddocument.unknown_typebefore. Riddler.Screens.Document.validate/1refuses a choice question's defects with five new codes, each naming the question's key:document.invalid_select,document.empty_answer_options,document.invalid_option,document.duplicate_option_valueanddocument.invalid_selection_bounds; an option label outside the template subset isdocument.invalid_template, anddocument.invalid_stylenow covers a choice question'sstyle; thedocument.unknown_typemessage, which lists the types this version knows, now also nameschoice_questionandboolean_question.Riddler.Screens.Document.admit/1and the document schema refuse a choice question whoseanswer_optionsis not a list of objects with stringvalueandlabelfields, as for every field the schema types; on any other nodeanswer_optionsis still dropped whatever it holds.Riddler.Screens.resolve/2andresolve_screen/3render each choice question option'slabelagainst the root, reporting a missing variable under the question's key, and leave eachvalueas written; a selection bound written with no fractional part is admitted and resolved as its integer.Riddler.Screens.validate_screen/3andvalidate_screen/4check a response to achoice_questionand aboolean_questionwhatever the question declares, with at most one finding per question naming its key: nothing selected on a required question isresponse.required, a response not of the shape the type fixes (one string underselect: "one", a list of different strings under"many",trueorfalsefor a boolean question) is the newresponse.invalid_shape, a required boolean question answeredfalseisresponse.required, a value no option carries is the newresponse.not_an_option, and under"many"a count outsidemin_selectionsormax_selectionsisresponse.out_of_range; aformaton either type is never applied to its response, and no answer for any other node type changes.Riddler.Journey.present/3presents the status a host passes -{:screen, key},:waitingor:done- as a string-keyed map whose"state"is"screen","waiting"or"done", a screen carrying its resolved nodes and diagnostics, andRiddler.Journey.put_context/2replaces a root's"context"whole with the responses untouched; the corpus holds both asjourney.presentandjourney.put_context.
Changed
Riddler.Screens.Document.admit/1reads aschema_versionwritten with no fractional part as its integer, sovalidate/1accepts a document declaring1.0where it refused it withdocument.invalid_schema_version, andRiddler.Screens.resolve/2carries1onto the resolved document; aschema_versionwith a fractional part is still not a document.Riddler.Template.compile/1reports every refusal the parser used to hide when a template holds a tag the parser does not know: a branch or closing keyword written where no block of its own is open is refused as itself beside every other refusal, the closing tag of a block the author opened is no longer refused (outside the parts of a block the parser throws away, such as what stands before acasetag's firstwhen), a tag such asincludeno longer hides a refused tag, filter or liquid tag beside it, and a liquid tag holding such a tag now reports the liquid tag and the tags inside it where it reported a parse error, so a host that matched on the old findings should match on the new list.- A template whose tag carries text after its name that the tag does not read (a string on an
endif, a word on anelse,endunless,endfor,endcaptureorendraw, a note on acommentopener or anendcomment) is refused withtemplate.trailing_text_not_allowednaming that tag, where it compiled before; delete the text, or move a note into the comment's body. - The
messageof aresponse.undecidablefinding for a condition that compiles now says what the evaluator answered: a name the root does not carry, a value that is not true or false, or a condition that fails to evaluate; where the answer does not tell a missing key from an operand of the wrong type it says only that the condition could not be decided. Thecode,field,node_keyandpositionare unchanged, so a host that matches on the code reads what it read before; a host that matched the old sentence "could not be decided against the root this screen was validated with" matches the code instead.
Fixed
Riddler.Screens.Document.admit/1answersnilfor a document whosemetadatais written asnull, as it does for any other value that is not an object, because the document schema typesmetadataas an object; a document that leavesmetadataout is admitted exactly as before. A host that wrote"metadata": nullwrites an object, or leavesmetadataout.
[0.3.1] 2026-10-05
A documentation release. The code and the behavior of the package are the
same as in 0.3.0; what changes is what a reader finds on hexdocs, on hex.pm and
in the README. Documentation takes no changelog.d fragment, so this section
is written by hand.
Added
- An explanation page, "What Riddler is for: elements, templates and
conditions", at
docs/explanation/what-riddler-is-for.md. It is published on hexdocs and ships in the package, because the README links it by a relative path.
Changed
- The hexdocs sidebar groups pages by the kind of page they are. The README and this changelog stay ungrouped at the top, and the explanation page sits under Explanation.
- The decision records under
docs/adr/are no longer published as hexdocs pages. They are a record for contributors, and the README links them on GitHub. - The README is rewritten as an introduction: what the package is, why it exists, installation, one worked example, and a Documentation section that maps the pages under Learn, Do, Look up and Understand.
[0.3.0] 2026-09-19
The conformance release after 0.2.0, and the first tag a runtime written in
another language can vendor the conformance corpus from. The corpus capability
screens.validate_responses is screens.validate_screen, and
mix riddler.corpus no longer reads RIDDLER_SPEC_PATH or defaults to
../riddler_spec: it refuses to run without --to. The template subset
refuses {% liquid %} wherever the parser reads one; 0.2.0 admitted it in some
spellings after a raw or comment marker, a conformance gap: a template this
runtime admitted in a spelling a runtime implementing the subset need not
accept. Riddler.Screens.Document.admit/1 answers nil for field values and a
nodes the published document schema already refused, and the
document.invalid_condition and response.undecidable findings carry a source
position in position where there is one.
Added
- The conformance corpus states that a refused tag inside an
ifblock is the one finding, and that the closing tag ending the block is not refused; this package already answered that way. - The conformance corpus states that a
whenoperand the root does not carry is reported in both render modes: lenient mode renders the branch that holds and names the operand, and strict mode returns it as an error; this package already answered that way.
Changed
- Breaking: the conformance corpus capability
screens.validate_responsesis nowscreens.validate_screen, and its cases moved fromcorpus/screens/validate_responses.jsontocorpus/screens/validate_screen.json. The capability was named for a function this package removed; it now names the one it calls. Nothing else about the cases changed and every case answers what it answered before. A runtime held to this corpus should dispatch on the new string and read the renamed file: the old string is an unknown capability, not an alias. - The
document.invalid_conditionfinding carries the source position its own message names, inposition, for a condition the parser refused and located. A condition that never reached the parser - one that is not a string - still carries none, and its sentence still names none. The code, field, node key and message are unchanged, so a host switching on any of them needs no edit; a host readingpositionto point an author at the condition no longer has to parse that sentence for the line and the column. - The
response.undecidablefinding now says which of three things left the condition undecided - the root could not decide it, it is not valid predicator, or it is not a string - and carries the source position the compiler or the evaluator gave for it inpositionand in the message, where there is one. The code, field and node key are unchanged, so a host switching on the code needs no edit; a host asserting on the message text reads a new sentence.
Removed
mix riddler.corpusno longer reads theRIDDLER_SPEC_PATHenvironment variable. Pass the export directory as--to PATHinstead.mix riddler.corpusno longer defaults to../riddler_spec, and it refuses to run without--to,--checkincluded. Pass--to PATHnaming the directory to export into, or the directory holding the copy to compare.
Fixed
- The characters of an excluded tag written as a bracket subscript, as in
{{ responses["{% liquid %}"] }}, render as text instead of being refused, matching what the same characters in an ordinary string literal already did. - A tag or filter outside the template subset written inside an
elsifbody or acasebranch body is refused, where it compiled before. - A variable guarded by
defaultinside anelsifbody or acasebranch body is missing in neither render mode, as it already was everywhere else. Riddler.Screens.Document.admit/1answersnilfor a document whoseid,kind, screenkeyortitle, or nodekey,typeorconditionis there and is not a string,nullincluded, or whoseschema_versionis there and is not an integer. The published document schema already refused every one of them; 0.2.0 admitted them. Write each of those fields as a string, andschema_versionas an integer, or leave it out.document.invalid_idanddocument.invalid_titleare no longer raised: the only values that raised them are not a document.- A finding
Riddler.Screens.validate_screen/3and/4raise carries anode_keythat is a string ornil, as document validation's already did; a node keyed with anything else put that raw value on the finding before, which a host indexing findings by that field cannot look a node up by. Give every node a stringkey, whichRiddler.Screens.Document.validate/1already asks for asdocument.invalid_key. {% liquid %}, which the template subset excludes as an alternate spelling for tags it already admits, is refused where a template printed the characters of araworcommentblock's markers around it - from a string literal or from a bracket subscript - and so hid it from the check.Riddler.Screens.Document.admit/1answersnilfor a document carrying anodesthat is not a list of nodes on a node whose type reads none - every type butvariant, and a type the package does not know. The published document schema already refused it; 0.2.0 admitted it and said nothing. Remove thatnodes, or write it as a list of nodes, which is admitted and ignored.- Response validation reports a screen's findings where a button carrying no
key declared
validatesasfalseand the call named no pressed button; it answered:okwith every finding silenced before. Give every button in the document akey, whichRiddler.Screens.Document.validate/1already asks for asdocument.invalid_key. {% liquid %}, which the template subset excludes as an alternate spelling for tags it already admits, is refused wherever the parser reads one. In 0.2.0 a liquid tag holding only admitted tags compiled after araworcommentmarker written in a string that anendif,else,endfor,commentorendcommenttag discards, after a comment whose opener carried trailing text, or after araworcommentblock whose closer carried trailing text. That was a conformance gap: a template this runtime admitted in a spelling a runtime implementing the subset need not accept.- The characters of a liquid tag inside a
raworcommentblock are text where the block's opener or closer carries trailing text, as they are everywhere else inside such a block; 0.2.0 refused the template.
[0.2.0] 2026-09-18
The residue wave after the first release. Riddler.Screens.validate_responses/3
and /4 give way to Riddler.Screens.validate_screen/3 and /4, which validate
against the same root the host resolved the screen with, and
Riddler.Screens.resolve_screen/3 answers its diagnostics alongside the screen.
A condition the root cannot decide is a finding rather than a silent pass, and a
button that declares it validates nothing is unaffected. A pattern the format
cannot compile is refused against the document before a visitor arrives, the
document schema is emitted as schemas/screen-document.schema.json and the
schemas ship in the package, and the emitted corpus header names no version, so
an emitted corpus is byte-identical across releases. A finding carries the
position in the source it refused, and a template the parser refuses without
saying where answers a finding rather than raising.
Added
- A finding carries
:position, the%{line: line, column: column}in the source it refused, so an editor can point at a refused template construct without parsing the message. A template refusal sets it, and adocument.invalid_templatefinding carries the position of the template refusal it wraps; every other finding leaves itnil. The message goes on naming the position in its own words wherever there is one. Riddler.Screens.validate_screen/3and/4validate against the root the host resolved with, so a required question acontextcondition showed the visitor is one the visitor can fail, where the removed pair resolved against an emptycontextand let the blank through.- The JSON schemas ship in the package, so a host that validates a document
against one at runtime can read it from
Application.app_dir(:riddler, "priv/schemas")instead of vendoring a copy.
Changed
- Breaking:
Riddler.Screens.resolve_screen/3answers{:ok, screen, diagnostics}where it answered{:ok, screen}; match on the three-element tuple and readmissing_variablesandundecidable_conditionsfromdiagnostics, which is the shaperesolve/2already carries on a resolved document, narrowed to the one screen. A caller that only wants the screen matches{:ok, screen, _diagnostics}.{:error, :no_such_screen}is unchanged, and so is every other function in the module. - Breaking:
Riddler.Screens.validate_screen/3and/4answer{:error, [%Riddler.Finding{code: "response.undecidable"}]}where they answered:okfor a screen carrying a condition the root could not decide; the finding names the node innode_keyand"condition"infield. A button declaringvalidatesasfalseis unaffected and still answers:okwithout running a check. Carry in the root everycontextandresponseskey the document's conditions read, giving a key the visitor has not answered yet its blank value rather than leaving it out, or change the condition to one that decides against a root without it. - A question that asks for the
patternformat and declares apatternthat format cannot compile is now a finding against the document,document.invalid_patternfromRiddler.Screens.Document.validate/1, raised before a visitor arrives. Response validation no longer reports it: the defect is in the document, and nothing a visitor could type would satisfy such a pattern. This is breaking for a host that matched the oldresponse.formatfinding with the fieldpatternon submission for such a question - readdocument.invalid_patternfrom document validation instead, and correct the expression in that question. Apatternon a question that asks for another format, or for none, is unchanged: it is a field nothing consults, and it carries no finding at either layer whatever it says. A question naming thepatternformat and declaring no pattern at all is unchanged too and still answersresponse.formatwith the fieldpattern, under new wording that names the missing pattern rather than calling it unusable. - The document schema is emitted as
schemas/screen-document.schema.json; point a consumer that reads the schema by its old path at the new one. - The
generated_byheadermix riddler.corpuswrites into each emitted case file names the source file and no version, so an emitted corpus is byte-identical across releases; re-emit once to pick up the new header, and point anything that parsed a version out of that string at the request that carried the emit instead. Riddler.Screens.Document.validate/1reports the envelope and boolean shapes the screen document record states, each only where the document carries the field:document.invalid_schema_versionfor aschema_versionother than 1,document.invalid_idfor anidthat is not a string,document.invalid_titlefor a screentitlethat is not a string,document.invalid_requiredfor a question'srequiredthat is not a boolean, anddocument.invalid_validatesanddocument.invalid_stylefor a button'svalidatesthat is not a boolean andstylethat is not a string. A document that carried one of these off-shape validated clean before and now carries a finding; correct the field in the document, or omit it, since an absent field is unchanged and still raises nothing.
Removed
- Breaking:
Riddler.Screens.validate_responses/3and/4are gone; callRiddler.Screens.validate_screen/3or/4with the same root you resolved the screen with, moving the responses map you used to pass into that root under"responses".
Fixed
- A variable used only in the condition of an
unlessis no longer reported as missing, in either render mode, which is what anifcondition already did. Riddler.Template.compile/1andRiddler.Screens.Document.validate/1answer findings rather than raising for a template the parser refuses without saying where, such as{% render %}or{% assign e %}; the finding carries thetemplate.parse_errorcode, no position, and a message that names no place.Riddler.Screens.Document.validate/1tells an author that a node or screen key is not a string instead of telling them there is no key: a key of the wrong form keepsdocument.invalid_keyand now carries a message naming the key that was written, distinct from the message for a key that is absent. A host matching on the message rather than on the code sees the new wording.- A finding's
node_keyis always a string ornil, asRiddler.Finding's typespec has always said. A node whose key was not a string used to put that key on every other finding about the node; those findings now carrynil, the same as a node that declares no key at all, and the key is named in the message. A host indexing findings bynode_keyno longer has to expect a value it cannot look a node up by. - A template that prints the characters of a
{% liquid %}opener from a string literal compiles and renders them, instead of being refused as though it held the tag.
[0.1.0] 2026-09-14
The first release. Riddler is a dynamic content runtime: a host authors content as JSON documents - screens now; emails, images and feature flags forthcoming - and Riddler resolves each against a visitor's context, while the host renders, sends or serves what comes back. A document is admitted and validated on its own, resolved against a host's context and a visitor's responses into the nodes that visitor is shown, its templates compiled and rendered against the subset, and the responses it collects validated against the screen they were shown. The conformance corpus is authored here and emitted into riddler_spec so a second implementation runs the same cases.
Added
Riddler.Template.compile/1accepts a template only when every tag and filter in it is inside the template subset, reporting oneRiddler.Findingper refused construct rather than the first.Riddler.Template.render/3renders a compiled template as text in lenient or strict mode, returning the paths that were missing in lenient and an error carrying them in strict.- A screen document is admitted from decoded JSON and validated on its own, with every reason it is refused reported at once: unknown node types, duplicate or misshapen keys, missing fields, heading levels out of range, conditions that do not parse, templates outside the subset, malformed writes, unknown formats, and variants that are empty or bury a default.
- A visitor's responses are validated against the screen they were shown:
required, theemail,phone,pattern,integerandnumberformats, andminandmaxon the numeric ones, with a question a condition hid unable to fail and a button declaringvalidatesfalse able to submit without any check at all. A question may now carrypattern,minandmaxbeside itsformat. - A screen document resolves against a host's context and a visitor's responses into the nodes that visitor is shown, with hidden nodes absent, every container collapsed to its winner, every template rendered, and what could not be decided reported rather than refused.
mix riddler.corpusemits the conformance corpus and the JSON schemas into a riddler_spec checkout, byte-stable and with agenerated_byheader naming the version and the source file, refusing to emit a corpus this implementation does not satisfy;--checkreports drift instead of writing.- A document's envelope carries
kind, the content kind it belongs to. It is an optional string and it defaults toscreens, so a document that names none is a screen document; the decided kind is carried through to the resolved document, and a kind this package has no runtime for is adocument.unknown_kindfinding naming the value.
Changed
- The screens kind is named after the kind rather than after the nodes inside
a screen:
Riddler.Elementsand everything under it is nowRiddler.Screens, and the corpus capabilitieselements.admit,elements.resolveandelements.validate_responsesare nowscreens.admit,screens.resolveandscreens.validate_responses, emitted fromcorpus/screens/. NoRiddler.Elementsname and noelements.*capability survives this release.templates.renderis unchanged.