Status: accepted
Context
ADR-0001 decided that the content document is the contract between a host and
this runtime, that it is JSON, that it carries a schema_version, and that a
content kind is a document shape, a resolved shape, a registry and a corpus
capability. This record decides the document of the one kind v1 ships, the
screens kind: the envelope, what a screen is, what a node is, and what a
resolved screen is. It decides nothing about any other kind.
A document exists so that a host can declare what a visitor is shown without declaring how it is shown, and so that a second runtime in another language can be held to the same answer. Everything below is therefore written as a rule about the document, not about a renderer and not about Elixir.
The public fixture priv/fixtures/signup_screens.json in statifier_examples
(read at 9d288c9) is the shape a host already authors by hand: an envelope, a
metadata block, three screens of a signup wizard, and eighteen nodes over four
types, with a key on every node, a condition on six of them, one format, and
Liquid output in four strings. It is the worked case this record has to admit.
The second piece of public evidence is docs/spikes/SF040-element-editor.md in
statifier_blocks (added by that repository's PR 465; present at cdfbc6c),
which re-authored that fixture in an editor vocabulary that had no screen
container and no per-node key, condition, write or format. Its measurement is
what makes this record's rules non-obvious: 64 fields walked, 39 carried, 25
lost, with the editor reporting zero findings for every loss. The losses were a
node key on anything that was not a question, every condition, every write,
the one format, and the screen container itself - the fixture is one document
holding three screens, and what came back was three documents, each rooted at a
heading, with each screen's key homeless and the envelope gone. Two smaller
findings in the same document bear on rules below: a required presentation field
the fixture had no value for, so buttons were given a style to clear a finding;
and one concept that survived under a different spelling, which is why the
vocabulary rules below are stated rather than assumed.
A vocabulary can lose a third of a document and say nothing. That is the failure this record is written against: what a document may carry is enumerated, and what it may not is refused loudly.
Decision
A document is a JSON object with an envelope and screens. The envelope is
schema_version, an integer, 1 in this version; kind, a string, optional;
id, a string that names the document; and metadata, an object. metadata
carries name, description and domain as strings and is otherwise an open
map: a host may put what it likes beside them, and this package neither
interprets nor refuses the extras. A document with no screens is refused.
kind names the content kind the document belongs to, and it defaults to
screens when absent. A document that omits kind is a screen document, so
every document authored before kinds existed is still admitted unchanged. A
kind the registry of kinds does not know is an admit finding,
document.unknown_kind, naming the value; it is not passed through silently,
because a document resolved by the wrong kind's rules is worse than a document
refused. The decided kind is carried through to the resolved document, so a
host holding a resolved document can tell what it is holding without the
document it came from.
screens is the screens kind's body key, and every other kind carries its
own. The key that holds a kind's content is the kind's to name; nothing here
reserves a shared body key across kinds, and a second kind's record names its
own.
A screen is key, title and nodes. nodes is an ordered list, and the
order is the order the visitor is shown. A screen with an empty nodes list is
admitted: a screen whose every node is conditional may legitimately resolve to
nothing, and refusing the empty list at admit time would refuse it at authoring
time too.
Every node has a type, and type is an open string in the schema while the
Elixir registry refuses a type it does not know. Those are the two halves of
one rule. The schema does not enumerate types, because a schema that did would
have to be revised in riddler_spec before a type could be added here, and
because a host that carries a document through a pipe should not have it
rejected by a validator older than the type. The registry does enumerate them,
so an unknown type is an admit finding naming the type and the node, never a
node silently passed through.
Every node has a key. It matches [a-z][a-z0-9_]*. Every key in a
document - a screen's or a node's - is unique across the whole document, not
merely within its screen. The key is how a diagnostic names a node, how a
response is addressed, and how a host correlates a resolved node with the node it
was authored from; a vocabulary that gives keys only to the nodes it thinks need
them has already lost the ability to say which node a finding is about.
A node may carry a condition, and a condition is a predicator expression
over context.* and responses.*. Three cases, and they are different
things. A condition that does not parse is an admit finding: it is wrong about
the document, and the author should be told before a visitor arrives. A
condition that parses but cannot be evaluated at resolve time - a variable the
context does not carry - hides the node and reports it in the resolved document's
diagnostics. A condition that evaluates false hides the node silently, because
that is not an error, it is the condition doing its job.
The node vocabulary, responses, buttons, outcome and writes are the
screens kind's and no other's. They exist because a screen is shown to a
visitor who fills it in and presses something; a kind with no visitor and no
submission carries none of them, and a kind that wants one of them says so in
its own record rather than inheriting it from this one. What is shared across
kinds is the template subset, conditions, containers, diagnostics and findings,
and those rules are written below for screens and generalised by the
forthcoming ADR-0004.
The v1 types are heading, text, text_question, button and variant.
Their fields are:
heading carries level, an integer from 1 to 6, and text.
text carries text.
text_question carries label; and optionally placeholder, required (a
boolean, default false) and format (a string naming a validation format the
package knows; email is the one the fixture uses). answer_options is the
field name reserved for the select question types - a choice question, a multi-select -
and those types are not built in this version. Naming the field now is what keeps
a later select question from arriving under a second spelling.
button carries label and outcome; outcome is required. It may carry
writes, a map whose keys are responses.<path> and whose values are the
two-element form ["const", value]. It may carry style, a string from an open
set the renderer owns, with primary and secondary named here so that a host
has something to render and an editor has something to offer; a style this
package does not recognize is passed through, not refused, because presentation
is not this package's to enumerate. And it may carry validates, a boolean
defaulting to true: a button that submits a screen validates that screen's
responses first, and the opt-out is what lets a Back button leave a half-filled
screen without an error.
variant carries nodes, a list of candidate nodes. First match wins: the
candidates are considered in order and the first whose condition holds is the
winner. A candidate with no condition is unconditional and wins if it is reached,
so the last candidate with no condition is the default; a candidate with no
condition that is not last makes every candidate after it dead. The winner
replaces the container in the resolved output, so a host that can render a
document can render a resolved one and nothing downstream needs to know a
container was ever there. A variant with no candidates is an admit finding.
text, label and placeholder are templates in the ADR-0003 subset.
They are compiled by the same code an editor calls, so a template this
package refuses is refused at admit time, with the node's key on the finding.
Any other string field in a document is literal text.
The host-supplied root is context and what a visitor submits is
responses. A response is addressed responses.<key>, where the key is the
key of the question node it belongs to; that is the whole of the correspondence, and
there is no second naming scheme for a field. Both roots are readable by a
condition and by a template.
The vocabulary is fixed here for every document, case and schema downstream.
The field a button carries is outcome; a document that spells it action is
refused. The field that records what a control sets is writes; a document that
spells it payload is refused. The root a visitor's submissions live under is
responses; a document that spells it answers is refused. These are refusals,
not renames: nothing is accepted and rewritten, because a document that is
accepted under two spellings will be authored under both.
A resolved screen is the same shape as the screen it came from. Same
envelope, same screen key and title, nodes in the same order, with four
differences and one addition. No node carries a condition: every condition has
already been decided, and a node that is present is a node that is shown.
Hidden nodes are absent rather than flagged. Variants are collapsed to their
winner. Every template field has been rendered to text. writes is carried
through unchanged, because it is what the host applies when the visitor presses
the button and is not this package's to resolve.
A resolved document carries diagnostics. It has missing_variables, a
list naming each template variable that was absent together with the key of the
node whose template wanted it, and undecidable_conditions, a list naming each
node key whose condition could not be evaluated. A resolved document with a
non-empty diagnostics is still a resolved document: resolution reports, it does
not refuse. Refusal is admit's job.
Enumeration is delegated to the code half's tests. The exact finding
messages, the full list of validation formats, and the field-by-field admission
rules are Riddler.Screens.Document's tests and the corpus emitted from them.
This record asserts what the categories are; a list in prose and a list in code
drift apart, and only one of them runs.
Consequences
Three code halves follow, each with the enumerating tests this record delegates
to. One builds Riddler.Screens.Document.admit/1 and validate/1 with the type
registry, and is held to admitting the fixture named in the Context with zero
findings. One builds Riddler.Screens.resolve/2, which produces the resolved
document described above, diagnostics included. One builds
Riddler.Screens.validate_responses/3 and its arity-4 form, which is where
required and format are enforced and where a button's validates is
consulted.
The first release of this package named those modules Riddler.Elements, after
the nodes inside a screen rather than after the kind they belong to. The code
half renames the tree to Riddler.Screens and the corpus capabilities from
elements.* to screens.* with 0.1.0, and adds the kind envelope field and
its document.unknown_kind finding at the same time; no Riddler.Elements
name and no elements.* capability survives that release.
The shared rules this record states - key uniqueness, conditions, the variant container, diagnostics and findings - are written here for screens because screens is the only kind v1 ships. They are generalised to every kind by ADR-0004, the shared content machinery, which is forthcoming and precedes the first non-screen kind's record. Until then a reader looking for a shared rule reads this record, and that is the reason ADR-0004 exists.
The JSON Schema for this document is written from this record, in the draft the
corpus test validates against, and lives beside the corpus. It enumerates the
envelope, the screen and the common node fields, and leaves type an open
string, exactly as the rule above says: the registry, not the schema, is what
refuses an unknown type.
Renderers own style and own everything else about presentation. This package
neither validates a style value nor ships a default for one.
Nothing here decides transport, authentication, streaming, the identity or durability of a visitor's execution, or the editor; ADR-0001 left those open and this record leaves them open. Nothing here decides the select question types: the field name is reserved, the behavior is not.
Typespecs
Minimal, and a contract for the code half rather than a second source of truth.
@type document :: %{
schema_version: pos_integer(),
kind: String.t(),
id: String.t(),
metadata: %{optional(String.t()) => term()},
screens: [screen()]
}
@type screen :: %{key: String.t(), title: String.t(), nodes: [node_t()]}
@type node_t :: %{
:type => String.t(),
:key => String.t(),
optional(:condition) => String.t(),
optional(atom()) => term()
}
@type resolved :: %{
schema_version: pos_integer(),
kind: String.t(),
id: String.t(),
metadata: %{optional(String.t()) => term()},
screens: [screen()],
diagnostics: diagnostics()
}
@type diagnostics :: %{
missing_variables: [%{key: String.t(), variable: String.t()}],
undecidable_conditions: [%{key: String.t(), condition: String.t()}]
}Worked examples
One per type. The two domains are the signup wizard the fixture authors and a multi-tenant host application that processes credit-card payments.
heading, from the signup wizard:
{ "type": "heading", "key": "account_heading", "level": 1, "text": "Create your account" }text, from the payments host - a template field, rendered before the visitor
sees it:
{
"type": "text",
"key": "card_intro",
"text": "Charging {{ context.tenant_name }} for {{ responses.seats }} seats."
}text_question, from the payments host, with a format and a condition:
{
"type": "text_question",
"key": "billing_email",
"condition": "context.send_receipts == true",
"label": "Where should the receipt go?",
"placeholder": "billing@example.com",
"required": true,
"format": "email"
}button, from the signup wizard, writing a response and naming what it raises:
{
"type": "button",
"key": "plan_business",
"condition": "responses.seats > 1",
"label": "Take the business plan",
"outcome": "business_chosen",
"writes": { "responses.plan": ["const", "business"] },
"style": "primary"
}A Back button on the same screen is where validates earns its place:
{
"type": "button",
"key": "plan_back",
"label": "Back",
"outcome": "went_back",
"validates": false
}variant, from the payments host - the first candidate whose condition holds
wins, and the last candidate carries no condition, so it is the default:
{
"type": "variant",
"key": "card_notice",
"nodes": [
{
"type": "text",
"key": "card_notice_declined",
"condition": "context.last_charge_status == 'declined'",
"text": "The card on file was declined. Try another one."
},
{
"type": "text",
"key": "card_notice_expiring",
"condition": "context.card_expires_within_days < 30",
"text": "The card on file expires soon."
},
{
"type": "text",
"key": "card_notice_default",
"text": "We will charge the card on file."
}
]
}A resolved screen. Take the signup wizard's plan screen, resolved against a
context and a set of responses in which responses.seats is 3 and
responses.first_name is Ada. The conditional hint is shown, the personal-plan
button is hidden because its condition is false, and the business-plan button and
the Back button are shown; every template has been rendered and no condition
survives:
{
"schema_version": 1,
"kind": "screens",
"id": "edoc_signup_screens",
"metadata": { "name": "Signup screens", "description": "...", "domain": "signup" },
"screens": [
{
"key": "plan",
"title": "Pick a plan",
"nodes": [
{ "type": "heading", "key": "plan_heading", "level": 1, "text": "Pick a plan" },
{
"type": "text",
"key": "plan_intro",
"text": "You can change this later. Nothing is charged today."
},
{
"type": "text_question",
"key": "seats",
"label": "How many seats?",
"placeholder": "1",
"required": false
},
{
"type": "text",
"key": "plan_business_hint",
"text": "More than one seat puts you on the business plan, Ada."
},
{
"type": "button",
"key": "plan_business",
"label": "Take the business plan",
"outcome": "business_chosen",
"writes": { "responses.plan": ["const", "business"] }
},
{ "type": "button", "key": "plan_back", "label": "Back", "outcome": "went_back" }
]
}
],
"diagnostics": { "missing_variables": [], "undecidable_conditions": [] }
}Recorded 2026-09-14, campaign RF049, bead rd-n0j. Rewritten in place while
still proposed on 2026-09-15, campaign RF049, bead rd-9wd: the record is now
the screens kind's document, the envelope carries an optional kind, and the
rules that are screen-only are named as such.
Accepted 2026-09-15, campaign RF049, bead rd-5c3, after a claim-by-claim
reading against main at b790db1 (riddler 0.1.0, published). The code halves
that built what this record decides: Riddler.Screens.Document.admit/1,
validate/1 and the type registry (PR 6), resolve/2 and resolve_screen/3
(PR 7), and validate_responses/3 and /4 (PR 8), with the corpus and its
screens.* capabilities (PR 9) and the rename and the kind envelope field
(PR 15). 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.
Four places where this record and the code it describes do not yet line up are
carried as notes by addition rather than as corrections, each with a bead:
the envelope shapes this record states - schema_version an integer, id a
string, required and validates booleans, style a string - are stated here
and not yet checked by validate/1, which is a gap in the code and not in this
record (rd-xxb); the code raises a finding for an unreachable variant candidate,
which this record describes as dead weight without saying it is refused
(rd-d8e); resolve_screen/3 returns a screen without the diagnostics its
resolution produced, which this record does not decide either way (rd-439); and
metadata's name, description and domain are named here as what the block
carries without a requiredness rule, and none is required in the code (rd-xvv).
Amendment, 2026-09-17: the screen validated is the screen shown
Status: accepted (2026-09-18)
This record decided what a screen document is, what a resolved screen is, and that a set of responses is checked against the resolved screen and nothing else. That last rule is right. What v1 shipped got it wrong in one place, and this amendment changes what the record decides there.
What the record now decides
Validation resolves the screen against the same root the host resolved
with. A check on a visitor's responses takes the context and the
responses the host had in hand when it showed the screen, resolves that
screen against them, and checks what comes back. The empty context is not
the contract. A screen resolved for validation and the screen the visitor was
actually shown are the same screen, and a rule stated about one holds of the
other.
The principle this record already carries - that a node a condition hid is a node the visitor never saw and cannot be held to - is unchanged and is the reason for the amendment rather than a casualty of it. Hiding is decided by the host's root, because that is the root the visitor was shown through.
What v1 did, and why it was wrong
Riddler.Screens.validate_responses/3 and /4 built their own root out of
the responses they were handed and an empty context
(lib/riddler/screens.ex, in the arity-4 body, read at
e7d76bf07f43e1ff0473d511857e42ff2b770b3e). The module documentation stated
that as the intent: a screen whose question is conditional on context was
said to resolve the way it would for a visitor the host knows nothing about.
The consequence is a false pass, and it is reproducible. Take a screen with
two required text questions: full_name, unconditional, and vat_id, carrying
the condition context.is_business == true. A host resolves that screen under
the root %{"context" => %{"is_business" => true}, "responses" => %{}}. Both
questions come back, with clean diagnostics, and the visitor is shown both. The
visitor fills in full_name, leaves vat_id blank, and submits. Validation
answers :ok.
It answers :ok because validation did not resolve the screen the visitor saw.
It resolved a different screen, against a root whose context is empty, on
which context.is_business == true cannot be decided; an undecidable condition
resolves to hidden; and a hidden question cannot fail. The same blank on
full_name, which no condition guards, answers
{:error, [%Riddler.Finding{code: "response.required"}]} - so the check works,
and only the conditional question slips through it.
The signal that something was wrong was already being produced and thrown away.
Resolving against that internally built root yields
undecidable_conditions: [%{key: "vat_id", condition: "context.is_business == true"}].
resolve_screen/3 discards the diagnostics it computes into an
underscore-bound tuple element, so validation never sees them. The node was
hidden from the validator, not from the visitor, and nothing on the return
distinguished those two cases.
This was not a gap in the rules this record states. It was a defect, in that the code did not resolve against the root the record's own reasoning assumes, and a defect in this record too, in that it did not say which root validation resolves against and so left the empty one defensible. This amendment says it.
The surface
The function is named validate_screen, and it takes the same root that
resolve/2 and resolve_screen/3 take:
validate_screen(document, screen_key, root, pressed_button_key \\ nil)root carries context and responses, exactly as it does everywhere else in
this package. The responses being checked are the ones inside that root; there
is no second place to put them.
validate_responses/3 and /4 are removed, not deprecated. Two reasons,
and the first is the weaker one. A new positional argument cannot be added to
the existing name: validate_responses/4 already takes a bare guarded map()
in the third position, so a root map added as a fourth positional would be
indistinguishable from the pressed button key at the call site, and a root map
added as a third would be indistinguishable from the responses map. The arity
is taken. That forces a new name; it does not by itself force the old name out.
What forces the old name out is that a lenient twin of a sound function invites
exactly the mistake this amendment fixes. validate_responses/3 is the shorter
call, the one already in the documentation, and the one a host reaches for when
it has responses and has not thought about the root. Keeping it alive under a
deprecation notice keeps the false pass available to every caller who does not
read the notice, and the failure mode is silent: a required question is not
asked about, and the submission is accepted. A deprecation warning is the wrong
instrument for a check that wrongly answers :ok. This package is at 0.1.0 and
a breaking change before 1.0 is the cheap moment to make it, so the removal is
the whole change and there is no transitional surface.
This is a breaking change to a public function of 0.1.0. It ships in the next release cut after it lands.
An undecidable condition is a finding
When the screen resolved for validation carries a condition that cannot be
decided against the root it was given, validation answers a finding rather than
:ok. It does not answer :ok by treating the node as hidden, and it does not
answer :ok by treating the node as shown.
This follows from the amendment above rather than standing beside it. Once
validation resolves against the host's real root, an undecidable condition
means the root the host handed in does not carry what the document asks about -
which is a defect in the call, not a property of the visitor. Answering :ok
would return the package to the behaviour this amendment removes, in a narrower
case. The diagnostic already exists at the point it is currently discarded; the
change is to surface it rather than to compute it.
The code half is a separate bead (rd-4a2 for the surface, and the finding
itself behind it); this record decides that the behaviour is a finding and
leaves the finding's code to the bead that raises it. This record does not
introduce a finding-code registry: Riddler.Finding carries a code string and
no enumeration, and nothing here changes that.
A single-screen call returns its diagnostics
This record's foot note carried an open question: whether resolve_screen/3
returns the screen's diagnostics beside the screen, which the record did not
decide either way (rd-439). The question can no longer be deferred, because
the validation surface this amendment decides is itself a single-screen call
that now has diagnostics it must either return or drop, and dropping them is
what produced the defect above.
It returns them. A single-screen call answers with the screen and the
diagnostics its resolution produced, so a caller is never handed a screen whose
resolution reported something without being handed the report. resolve_screen/3
answers {:ok, screen, diagnostics}, with diagnostics the same shape
resolve/2 already carries on its resolved document: missing_variables and
undecidable_conditions.
This is a second breaking change in the same release, and it is recorded here
so that it is not discovered as a surprise. resolve_screen/3 is public, is
documented with executable examples that match on {:ok, screen}, is cited by
ADR-0001 as the call that resolves one named screen, and is called inside this
package by the corpus runner. Every one of those changes with the return. The
alternative - leaving resolve_screen/3 alone and giving only the new
validation call its diagnostics - was rejected because it leaves two
single-screen calls in one module disagreeing about whether a caller is told
what resolution found, and that disagreement is the shape of the defect this
amendment exists to close.
What is unchanged
The document, the node vocabulary, the resolved shape, the writes and
outcome fields, the lenient template rendering, the missing_variables and
undecidable_conditions diagnostics and what goes in them, and the rule that
checks run over the resolved screen and nothing else. resolve/2 keeps its
arity and its return. Earlier prose in this record and in ADR-0001 that names
validate_responses describes what v1 did and stays as the historical record
of it.
Noted 2026-09-17, campaign RF051, beads rd-gwn, rd-d8e, rd-xvv and rd-xva.
Four notes by addition, each read against main at 7a2dd8f. They record
what this record was silent on; none of them changes what it decides, and
none of them bears on the amendment above.
A decoded document reaches the runtime atom-keyed, except metadata. The
Typespecs section above writes metadata as %{optional(String.t()) => term()} while a screen and a node are atom-keyed, and never says which of
those two forms a host's decoded JSON arrives in. Both do, and the boundary
is admit/1. What a host hands in is the decoded JSON map, string keys
throughout, as Jason.decode!/1 gives it; what the rest of the package reads
is the struct admit/1 builds (lib/riddler/screens/document.ex, admit/1,
read at 7a2dd8f). In that struct the envelope fields, every screen
(%{key: ..., title: ..., nodes: [...]}) and every node are atom-keyed, and
metadata alone is carried through with its string keys exactly as written.
The reason for the exception is the envelope rule above that metadata is an
open map: a host may put what it likes beside name, description and
domain, so its keys are not drawn from any vocabulary this package knows,
and string keys are the only form that can hold them without this package
turning host input into atoms. The typespec is right as it stands; this note
says which side of admit/1 each form lives on. The same reasoning is why no
unrecognized node field becomes an atom either: admit/1 copies a field onto
a node only when that field's own name, as a string, is a key of the raw
node, so every atom in an admitted node comes from this package's vocabulary
and none from the document.
An unconditional variant candidate that is not last is an admit finding.
The variant rule above says such a candidate makes every candidate after it
dead and stops there, while the empty variant beside it is explicitly a
finding. This note decides that the unreachable case is one too, on the
reasoning the Context already gives: the loss is silent, and a vocabulary
that can lose part of a document without saying so is what this record is
written against. The code is document.unreachable_variant_candidate, one
finding per unconditional candidate that is not last, carrying the variant's
own key as the node key and nodes as the field, with the buried candidate
named in the message - the variant is the node the author has to fix. It is
raised by Riddler.Screens.Type.Variant.validate/1
(lib/riddler/screens/type/variant.ex, the private unreachable/2, read at
7a2dd8f), listed with the other codes in Riddler.Screens.Document's
moduledoc (lib/riddler/screens/document.ex, read at 7a2dd8f), and pinned
by a test and by the corpus case "An unconditional candidate that is not last
is refused, because it buries every candidate after it"
(corpus/screens/admit.json). The record and the code already agreed on the
behaviour; what this note closes is that the record did not say it.
None of metadata's name, description and domain is required. The
envelope rule above names the three as what the block carries and types them
as strings without stating a requiredness rule, which leaves a reader free to
read the naming as a requirement. It is not one. A document with no
metadata at all is admitted, and so is one whose metadata carries none of
the three: admit/1 takes the block whole when it is a map and substitutes
the empty map when it is absent (lib/riddler/screens/document.ex,
admit_metadata/1, read at 7a2dd8f), and validate/1 raises no finding
about metadata, having no check over the block at all. The three names are
a convention this record offers so that hosts and editors spell one idea one
way, not a schema this package enforces; a host that needs one of them
present enforces that itself. This note decides the open reading rather than
merely reporting the code: requiring any of the three would be a new refusal
of documents 0.1.0 admits, and the reason this record gives for naming them -
so that an editor has something to offer - is served without one.
A node field this version does not know is dropped at admit, with no
finding. This record enumerates each type's fields and is silent on what
becomes of a field outside that enumeration on a node whose type is known.
It is dropped: admit/1 copies the common fields and the fields the type's
own fields/0 names, and nothing else reaches the admitted node
(lib/riddler/screens/document.ex, admit_typed/3 and the private take/2,
read at 7a2dd8f); validate/1 raises nothing about it. That is the narrow
counterpart of the loud refusal of an unknown type rather than an exception
to it: an unknown type means this package cannot say what the node is, while
an unknown field on a known type is a field this package can say is no part
of the type. Dropping it rather than carrying it is also what keeps host
input out of the atom table. metadata is the declared place for what a host
wants to keep beside the vocabulary. Whether such a field should in addition
raise a finding is left open here exactly as it was before this note: naming
a code for it would change what this record decides, and that is an
amendment's work, not a note's.
answer_options is that rule's likeliest case, and this note names it. While
the select question types are unbuilt, a document carrying answer_options on
a text_question is admitted and the field is dropped, with no finding: the
reservation above reserves the spelling, not a behaviour. A host authoring
choices before those types exist is authoring something this version will not
show and will not mention.
The same enumeration under-names text_question in one further way, which
this note closes. That type also admits pattern, min and max
(lib/riddler/screens/type/text_question.ex, fields/0, read at 7a2dd8f).
They are parameters of the validation formats the rule above delegates to -
pattern is the expression the pattern format holds a response to, and
min and max are the bounds the integer and number formats hold one
between - and each is read only by the format that owns it, so a question
declaring one without the format that reads it declares something nothing
consults.
Noted 2026-09-17, campaign RF051, bead rd-xxb. One note by addition, read
against main at 342c790. It records the codes that check the envelope and
boolean shapes this record already states; it changes nothing this record
decides, and it does not bear on the amendment above.
The envelope and boolean shapes this record states are checked, one code
each, and only where the document carries the field. The Decision above
types schema_version as an integer and 1 in this version, id as a string
that names the document, a screen's title as a string, required and
validates as booleans, and style as a string from an open set the renderer
owns; the Typespecs section writes the first three as pos_integer() and
String.t(). v1 carried every one of them from the decoded JSON onto the
admitted struct without reading it, so a document declaring a schema_version
of 2, an id of 7 or a screen titled with a number validated clean. It no
longer does. The codes are document.invalid_schema_version for a
schema_version that is not the one this package implements,
document.invalid_id for an id that is not a string,
document.invalid_title for a screen title that is not a string,
document.invalid_required for a question's required that is not a boolean,
document.invalid_validates for a button's validates that is not a boolean,
and document.invalid_style for a button's style that is not a string. The
first two carry the field and no node key, because the envelope is the
document's and not any one node's; document.invalid_title carries the
screen's key, because the screen is what the author has to fix; the other
three carry the node's key. Each check is added by this bead's own commit, so
none of them is citable at the 342c790 this note was read against: the
envelope and title checks are Riddler.Screens.Document's (the private
envelope_findings/1 and title_findings/1 in
lib/riddler/screens/document.ex), and the three field checks belong to the
types that name the fields (the private required_findings/1 in
lib/riddler/screens/type/text_question.ex, and the private
validates_findings/1 and style_findings/1 in
lib/riddler/screens/type/button.ex). That is the same division that already
puts a heading's level range in the heading and a format's name in the
question (lib/riddler/screens/type/heading.ex, validate/1, and
lib/riddler/screens/type/text_question.ex, the format branch of
validate/1, both read at 342c790).
Three passages state the schema version, and the check took the narrowest
of them. The Decision above says schema_version is "an integer, 1 in
this version"; the Typespecs section writes pos_integer(); and the gap Note
above, in the sentence that names this bead, lists "the envelope shapes this
record states - schema_version an integer, id a string, required and
validates booleans, style a string - are stated here and not yet checked
by validate/1". Two of those three say only that it is an integer.
document.invalid_schema_version implements the narrowest reading: an integer
other than 1 carries the finding. The implementation therefore went
narrower than the sentence that commissioned it, and this note records that
rather than leaving a reader to discover it from the code. The Decision is
what governs where the three differ, on the Typespecs section's own terms - it
opens by calling itself "Minimal, and a contract for the code half rather than
a second source of truth" - and the gap Note's list is a summary of what was
unchecked rather than a fourth statement of the contract. A version that is
the runtime for a second schema version widens the check by amending the
Decision, which is the passage that decides. That same list names five of the
six shapes checked here; a screen's title is the sixth, and it comes from
the screen rule in the Decision and from the screen type in the Typespecs
rather than from that list.
The schema is the fourth reading and it is wider still: it types
schema_version as integer with no const
(priv/schemas/element-document.schema.json, read at 342c790), so a
document declaring 2 is schema-valid and carries a finding here. That is not a
disagreement between the schema and the runtime but the thing the schema's own
description already says, that a validator holding it and a runtime admitting
the same value agree because schema-valid means admitted - not that an
admitted document is free of findings. kind is the standing precedent: an
open string in the schema, enumerated by the runtime, and a kind no runtime
knows is schema-valid and a finding.
Each is a shape check and none of them is a requiredness check. A field
this record types but the document omits raises nothing: the schema requires
screens of a document and nodes of a screen and nothing else
(priv/schemas/element-document.schema.json, read at 342c790), so a
document with no schema_version, no id or no screen title is a document
this version admits and validates clean, exactly as before. That is the same
reading the note above takes for metadata's three names, and for the same
reason: requiring a field this record names would be a new refusal of
documents 0.1.0 admits, which is an amendment's work and not a note's; the
question is carried as rd-5v2. One
consequence is worth stating, because the struct cannot see it and because it
is not symmetrical across the six. On the envelope and on the screen an
omitted field and a field written as JSON null both reach the runtime as
nil, so a null schema_version, id or title reads as an absent one
and raises nothing, while the schema above types each of the three and refuses
the null. The three node fields answer the other way: required, validates
and style are read with Map.fetch/2 from a node that carries only the
fields it declared, so an explicit null arrives as a value that is present
and is neither a boolean nor a string, and each raises its own finding. The
same null is therefore refused on three of the six fields and accepted on
the other three in one call - probed on all six rather than reasoned about.
Which of the two answers should hold for all of them is carried as rd-6kl and
is not settled here, because making them agree either adds a refusal or
removes one. A host that wants a field present enforces that itself, or holds
the document to the schema.
style is checked for being a string and for nothing more. The rule above
that a style this package does not recognize is passed through rather than
refused is unchanged: primary and secondary are named there so that a host
has something to render, and any other name reaches the renderer untouched.
What the check adds is that the name is a name - a renderer handed a number
has nothing to look up - and it is the narrow counterpart of the open set
rather than an exception to it.
The Consequences section above has to be read with this note beside it. It
says that renderers own style and that "this package neither validates a
style value nor ships a default for one", and from this note's date that
sentence is true of only half of the distinction drawn here: which style a
value names is still never validated - no name is refused, no default is
supplied, and primary and secondary stay offers rather than an enumeration
- while that the value is a name at all now is. A reader arriving at that
sentence should read "validates a style value" as "judges which style it is",
which is what it was written to promise and what the check leaves untouched.
The gap Note above already authorizes the check, by listing
stylea string among the shapes this record states and the code did not check and naming this bead for it; what it did not do, and what this paragraph does, is say that the Consequences sentence reads the other way.
Nothing in the conformance corpus changes. Every case in the three
screens capability files was enumerated field by field - 71 of them, 70 with
an object for a document and one whose input is not an object at all. All 70
declare schema_version 1 and a string id, every screen title in them is a
string, and not one required, validates or style in them is off-shape,
so no case's stated answer moves and the case counts are untouched. The corpus gains no case here. The shapes are pinned by the suite
instead, by seven tests this bead's commit adds to
test/riddler/screens/document_test.exs: one per shape, each red before the
check it names existed, plus one that holds the checks to saying nothing about
a field the document omits.
Amendment, 2026-09-17: an uncompilable pattern is the document's defect
Status: accepted (2026-09-18)
This record decided which string fields of a document this package compiles,
and it decided that a validation format's own check belongs to response
validation. A pattern sits between those two rules, and v1 read it as
entirely the second one's. This amendment changes what the record decides
about one half of it.
What the record now decides
A pattern a question declares for the pattern format is an expression
the document is held to being compilable, and one that is not is refused at
admit, as document.invalid_pattern carrying the field pattern and the
node's key. Refused at admit means an admit-time finding and not a refusal
to admit: admit/1 is unchanged, a document carrying such a pattern is still
a document, and the finding comes from validate/1. That is what this record
means by an admit finding throughout, and it is what the schema says of
itself - everything a document can be wrong about "is a finding a runtime
raises against an admitted document, not a reason the value is not a
document", so that schema-valid means admitted
(priv/schemas/element-document.schema.json, read at 27f9d91).
Response validation no longer reports that case. A defect reported from two layers is worse than one reported late, and the response was never what was wrong: nothing a visitor could type satisfies an expression that does not compile.
The check follows the format, not the field. It raises only where the
question declares format as pattern. A pattern on a question that asks
for another format, or for none, stays exactly what the note above already
says it is - a field nothing consults - and carries no finding at either
layer. This amendment deliberately decides the narrower thing: it moves
which layer refuses an expression the pattern format cannot use, and it
does not widen which patterns are looked at.
The shape check is part of it, and is named here rather than left to the
code. A pattern that is not a string at all - a number, a boolean, or a
JSON null, which reaches an admitted node as nil - is not an expression
either, and it carries the same code and the same field. One code covers both
because they are one defect from the document's side: the question declares
something nothing can compile. This is the same kind of shape check the note
above names one code each for, and it is stated in the same way, so that a
reader learns it from the record rather than from a clause of the
implementation.
One case stays with response validation. A question that declares
format as pattern and declares no pattern at all. There is no
expression for the document check to read, so it raises nothing, and response
validation answers response.format with the field pattern - a response
cannot be in a form the question never states. Whether a format declared
without the parameter it reads should itself be a document finding is a
question this amendment does not decide.
Why an amendment and not a note
Because this reverses the layer the Decision assigns, and because it refuses documents this version admitted. Three passages have to be read to see that, and the first attempt at this change rested on two of them misread; they are set out plainly here so that the next reader does not have to rediscover it.
The record does not say a pattern is a regular expression. The phrase
appears nowhere in what this record decided, nor in any note on it; once this
amendment lands it occurs in the record exactly once, in the sentence you are
reading, which is a report of the phrase and not a use of it. What the record
says is that "pattern is the expression the pattern format holds a
response to", that pattern, min and max "are
parameters of the validation formats the rule above delegates to", and that
"each is read only by the format that owns it, so a question declaring one
without the format that reads it declares something nothing consults" (the
note above, read at 27f9d91). Every clause of that places the field on the
format's side and says nothing about this package compiling anything at
admit. The regular-expression wording is the code's, and at the SHA cited
above it is in two places rather than one: the moduledoc of
Riddler.Screens.Type.TextQuestion, and - the more telling of the two - the
text of the finding response validation raised, which is what a host's caller
actually read (lib/riddler/screens/validation.ex, the private
pattern_finding/1, read at 27f9d91). Neither is this record deciding
anything: a sentence in a moduledoc and a sentence in a runtime message are
both the code describing itself, and the second is the code describing itself
to a visitor's host. That second occurrence is gone as this bead leaves the
tree - the same commit reworded that message, because after the move it only
ever answers a question declaring no pattern at all - so a reader looking for
it reads it at the SHA cited here rather than on the current default branch.
The templates rule does not extend to it, and its next sentence says so.
That rule reads in full, word for word, with the record's own bold on the
first sentence and the bold on the last sentence mine: "text, label and
placeholder are templates in the ADR-0003 subset. They are compiled by the
same code an editor calls, so a template this package refuses is refused at
admit time, with the node's key on the finding. Any other string field in a
document is literal text." The record emphasises the rule; the emphasis
added here is on its limit, which is the clause the first attempt at this
change quoted around. A
pattern is another string field. Read whole, that passage does not license
treating a pattern as compiled-at-admit - it classes it as literal text as
far as the document is concerned, which is the opposite. So this amendment
does not extend an existing rule by analogy. It carves a third kind of string
out of that sentence: not a template, not literal text, but an expression one
format compiles, which the document is held to having written compilably. The
templates rule stands unchanged for the three fields it names, and "any other
string field is literal text" now has this one stated exception.
And the Consequences section assigns the other side to response
validation: it commissions the half that builds
Riddler.Screens.validate_responses/3 "and its arity-4 form, which is where
required and format are enforced". The compilability of a pattern was
part of enforcing format there, and this amendment takes it out. v1
implemented exactly what that sentence said: a question whose pattern was
[0-9 validated clean as a document and answered response.format with the
field pattern against whatever the visitor typed
(lib/riddler/screens/validation.ex, the private pattern/2 and
pattern_finding/1, read at 27f9d91).
The test the record sets for itself is met. The note above, deciding a narrower question, states it: requiring a field this record names "would be a new refusal of documents 0.1.0 admits", and naming a code for a question this record leaves open "would change what this record decides, and that is an amendment's work, not a note's". A document whose pattern does not compile validated clean in 0.1.0 and now carries a finding. That is a new refusal of a document this version admitted, and it changes which layer the record assigns a check to. It is an amendment's work on both counts, and it lands at proposed.
What is unchanged
A compilable pattern is enforced at response validation exactly as before,
against the whole response, anchored at both ends. admit/1 refuses nothing
new. No other format's parameters are read at admit: min and max are
untouched, and a question declaring them without the formats that read them
still declares something nothing consults. The fixture this record is held to
still admits and validates with zero findings. Nothing about
resolve/2, the resolved document, or the diagnostics it carries is touched.
Consequences
The check is text_question's, beside the format and required checks and
for the reason the note above gives for that division - the type that names
the field owns the check on it. It compiles through the same function the
format compiles through rather than carrying a second copy of the anchors,
because a document check holding an expression to anchors the format did not
apply would admit a pattern the format cannot use, or refuse one it can, which
is this defect reintroduced from the other end. That function is in
Riddler.Screens.Validation, which is @moduledoc false and no part of this
package's surface, as its own documentation says. Both are added by this
bead's own commit and so are citable at no earlier SHA.
One conformance case changes side, and the corpus gains none. The response
validation corpus stated ok: false with a response.format finding for a
checkout document whose card_last_four pattern was [0-9. That capability
now answers ok: true for it, so the case states that and its name says why.
No case is added and no case count moves. A case stating
document.invalid_pattern against the admit capability would pin the other
half of this boundary and is left for the corpus pass.
Noted 2026-09-17, campaign RF051, bead rd-mnp. One note by addition, read
against main at 4208433. It re-labels three of this record's own citations
because the change that carries this note renames the file they cite; it
records nothing new about the document, changes nothing this record
decides, and changes nothing either amendment above decides.
The document schema is cited three times above under the name it no longer
carries, and each citation is still sound. The file those three cite was
named priv/schemas/element-document.schema.json, and the change that carries
this note renames it to priv/schemas/screen-document.schema.json. The rename
is recorded as a rename at full similarity: the schema's content is untouched
by it, so each SHA those citations name still reads exactly what they say it
reads, under the file's earlier name. From this change onward the path to read
it at is priv/schemas/screen-document.schema.json. The three, each located by
the passage it sits in rather than by a line:
- In the passage above beginning "The schema is the fourth reading and it is
wider still", the citation "(
priv/schemas/element-document.schema.json, read at342c790)" supports its reading that the schema "typesschema_versionasintegerwith noconst". - In the passage above headed "Each is a shape check and none of them is a
requiredness check.", the citation
"(
priv/schemas/element-document.schema.json, read at342c790)" supports its reading that "the schema requiresscreensof a document andnodesof a screen and nothing else". - In the amendment above titled "an uncompilable pattern is the document's
defect", under "What the record now decides", the citation
"(
priv/schemas/element-document.schema.json, read at27f9d91)" supports its reading of what "the schema says of itself": the schema's own description, which that passage quotes as "is a finding a runtime raises against an admitted document, not a reason the value is not a document", and the conclusion that passage draws from it in its own words, that schema-valid means admitted.
Each reading stands as written. What moves is the path, and only the path: a
reader following any of the three at a tree carrying this note reads the same
schema at priv/schemas/screen-document.schema.json, and at 342c790 or
27f9d91 reads it at the name those SHAs carry.
Two other references to the earlier name are correct as they stand and are
deliberately untouched, by both this note and the change that carries it:
this record's own filename, which is how a record is cited and is fixed by its
number, and the row that links to it from the index in docs/adr/README.md.
Neither names the schema; both name this record.
Amendment, 2026-09-17: the per-button opt-out covers an undecidable condition
Status: accepted (2026-09-18)
Two amendments sit above this one. Every reference here to "the amendment above" means the first of them, headed "the screen validated is the screen shown"; the second, on an uncompilable pattern, is named in full where it is cited.
The amendment above decides that a condition the root could not decide is a
finding rather than :ok. It states that rule without qualification and it
never names the per-button opt-out, so it left open what a press that declares
it does not validate answers for such a screen. This amendment decides that:
the opt-out covers it.
What the record now decides
A button declaring validates as false answers :ok for the screen it
submits even when that screen carries a condition the root could not decide.
The finding the amendment above introduces is raised where the pressed button
validates. A press through a button that says nothing, and a press through a
key naming no button on the resolved screen, both still report it.
One narrower case is named here rather than claimed away, and this amendment
decides nothing about it. validates is read from the button the pressed
key names on the resolved screen, and a call naming no button passes a pressed
key of nil. A button node carrying validates as false and no key at all
therefore answers to that nil: a call naming no button is treated as a press
through it, and the screen answers :ok. This version admits such a document
and separately reports it as defective, because a button carries a key
(document.invalid_key, lib/riddler/screens/document.ex, read at
4208433d6f318acd00870cf2b5929cb2ccd97a69), so the case arises only for a
document a host has already been told is wrong, and nothing here is the reason
it behaves that way. Whether a keyless button should be able to opt out at all
is left open exactly as this amendment found it.
The Decision above already says what the opt-out is for: a button "may carry
validates, a boolean defaulting to true: a button that submits a screen
validates that screen's responses first, and the opt-out is what lets a Back
button leave a half-filled screen without an error." That sentence is about
navigation. A visitor pressing Back is leaving the screen, not submitting it,
and there is nothing yet to be right or wrong about.
Why the opt-out wins here
The amendment above gives its own reason for the finding, and read closely that reason is what carves this case out rather than what swallows it. It says: "Once validation resolves against the host's real root, an undecidable condition means the root the host handed in does not carry what the document asks about - which is a defect in the call, not a property of the visitor."
A defect in the call is the host's. A Back press is the visitor's, and it is
their navigation rather than their submission. Holding a visitor at a screen
they are trying to leave, in order to report a mistake in the host's root,
charges the wrong party for it: the visitor cannot fix the root, cannot see the
finding, and has not claimed that anything on the screen is finished. The host
still learns of the defect, from the same finding, the moment a press that does
validate arrives - and from resolve/2 and resolve_screen/3, which report
undecidable_conditions in their diagnostics on every call whatever is pressed.
Nothing is hidden by this; one door out of the screen stops being blocked.
The reading the other way is defensible and was implemented first. The
amendment's rule is stated flat; an undecidable condition is not something a
visitor typed their way into; and the sentence that follows the one quoted
above presses the point - "Answering :ok would return the package to the
behaviour this amendment removes, in a narrower case." What decides between
the two readings is what that removed behaviour was: a submission accepted
unchecked. A non-validating press accepts no submission, so answering :ok
to one does not return the package to it. That is the case the amendment's
reasoning does not reach, and the record above neither reached it nor said so.
It did not say so anywhere else either. The amendment above names validates
nowhere and the opt-out nowhere. A button reaches it twice and both times as
the pressed_button_key argument of the surface it declares - once in the
signature and once in the argument about which positions are taken - which is
an argument's name and not a rule about what pressing one does. Its
"What is unchanged" list - "The document, the node vocabulary, the resolved
shape, the writes and outcome fields, the lenient template rendering, the
missing_variables and undecidable_conditions diagnostics and what goes in
them, and the rule that checks run over the resolved screen and nothing else" -
does not name the opt-out among what it preserves. So the question was open in
both directions rather than decided in one, which is the condition this
amendment exists to end.
Why an amendment and not a note
Because it changes what this record decides, which is the test this record states for itself. The note above that decides what becomes of a node field this version does not know declines to decide whether such a field should also raise a finding, and gives its reason in these words: "naming a code for it would change what this record decides, and that is an amendment's work, not a note's." The operative clause is changes what this record decides, and this entry does: the rule stated above answers a call one way, and this entry answers the same call another.
Deciding something the record left open is not by itself that test, and this
record refutes the confusion by example. The metadata requiredness note
says of itself "This note decides the open reading rather than merely reporting
the code" - and remains a note. It sits in the same note block as the sentence
just quoted, at the end of the paragraph immediately before it, so a reader
following that citation lands beside it. It can remain a note because its
decided reading takes nothing away, and so changes nothing this record had
decided. Deciding an open question and changing what the record decides come
apart, and it is the second that governs.
The provenance lines the note blocks carry say the same thing from the other side, each in its own words. The four-note block: they "record what this record was silent on; none of them changes what it decides, and none of them bears on the amendment above." The single-note block, of its own note: "it changes nothing this record decides, and it does not bear on the amendment above." This entry fails the operative clause in both spellings twice over. It changes what the record decides, and it bears on the amendment above as directly as an entry can, being a qualification of one of its rules.
The other test this record uses points the other way and is not the governing
one. The amendment on an uncompilable pattern asks whether a change is "a new
refusal of documents 0.1.0 admits". This is not: it takes nothing away that 0.1.0
allowed, and restores for one press exactly the :ok that 0.1.0 always
answered there. That test is a sufficient reason for an amendment, not a
necessary one, and a change can fail it and still be an amendment's work.
Two further reasons, both practical. This qualifies a rule stated by an
amendment that is itself at proposed; a note cannot be accepted or rejected
with the text it qualifies, while a second amendment beside the first can be
read and ruled on as one. And what it settles is a behaviour a host writes code
against, not a consequence of a rule already decided - the rule above and this
one give a different answer to the same call.
What is unchanged
Everything the amendment above lists as unchanged, and the amendment itself
apart from the one qualification stated here: a validating press still answers
the finding, missing_variables still becomes no finding, a condition the root
decides false still hides its node silently, and the order of the findings is
untouched. validates keeps its default of true, its meaning, and its
admit-time shape check. resolve/2 and resolve_screen/3 are not touched by
this amendment - resolve_screen/3's return is changed by the amendment
above, which states that and says why - and their diagnostics report an
undecidable condition whatever button is pressed, because no button is pressed
at resolution.
One edge stays exactly where the amendment above left it, and this amendment
decides nothing about it. A button's own condition may be the undecidable
one. Resolution then drops that button from the screen, so nothing on the
resolved screen declares validates at all, and the press falls to the
default: it validates, and the finding is raised. That the press validates is
what the @doc on Riddler.Screens.validate_screen/4 already states for any
key naming no button on the resolved screen; that the finding then follows is
this amendment's rule applied to it, not something that @doc says. Neither is
changed here. Whether a press through a
button hidden by its own undecidable condition should reach the opt-out this
amendment carves out is a question this amendment does not answer.
The public documentation of the opt-out needed no change. The @doc on
Riddler.Screens.validate_screen/4 (lib/riddler/screens.ex, read at
4208433d6f318acd00870cf2b5929cb2ccd97a69) and the matching passage in
README.md (read at the same SHA) both already say that a button declaring
false answers :ok without running a check, which is what lets a Back button
leave a half-filled screen. Under the first implementation of the amendment
above that sentence had become false. The code moving to what this amendment
decides is what makes it true again, so neither sentence is edited by the
commit this amendment lands in.
The conformance corpus gains nothing and loses nothing here. The one case
the finding's bead adds to corpus/screens/validate_responses.json presses no
button, so its stated answer is what this amendment leaves it. A case pressing
a non-validating button on a screen with an undecidable condition would pin
this amendment from the corpus side and is left for the corpus pass.
The code half is the same bead as the finding itself, whose commit is the one
that adds both the entry point taking the diagnostics behind the opt-out check
and the pair of tests pinning the two halves against one document, so neither
is citable at any earlier SHA. The behaviour is in Riddler.Screens.Validation,
which is @moduledoc false and no part of this package's surface, reached from
Riddler.Screens.validate_screen/4 in lib/riddler/screens.ex.
Noted 2026-09-18. The three amendments above move from proposed to
accepted, each Status line flipped in place and nothing else in them
reworded. They are, in the order they appear: "the screen validated is the
screen shown", "an uncompilable pattern is the document's defect", and "the
per-button opt-out covers an undecidable condition".
Each was verified against main at 015f209 before the flip rather than
against the tree it was written on, because an amendment is accepted for what
the package does now. What was read, by anchor:
- The validation surface is
Riddler.Screens.validate_screen/3and/4, taking(document, screen_key, root, pressed_button_key \\ nil)and resolving the screen against the root it is handed (lib/riddler/screens.ex, the twovalidate_screenclauses and their@specs).validate_responses/3and/4are gone from the public surface; the name survives only as the corpus capabilityscreens.validate_responsesand its case file, which name a capability rather than a function. resolve_screen/3answers{:ok, screen, diagnostics}(lib/riddler/screens.ex, its@specand body), so the single-screen call returns the diagnostics its resolution produced.- An undecidable condition is the finding
response.undecidableon the node carrying it, withfield"condition", reported wherever the pressed button validates (lib/riddler/screens/validation.ex). - A
patterna question declares for thepatternformat is held to being compilable at the document layer, asdocument.invalid_patternon the fieldpatternand the node's key, and only where that format is declared (lib/riddler/screens/type/text_question.ex,pattern_findings/1and the private clauses under it). Response validation no longer reports that case and raises only for a question declaring the format and no pattern at all (lib/riddler/screens/validation.ex, the privateunreadable_pattern/1). The shape check is the same code: apatternthat is not a string compiles to:errorthrough the one compiler both layers share. - A button declaring
validatesasfalseanswers:okfor a screen carrying an undecidable condition, and a button that says nothing, or a key naming no button, still reports the finding (lib/riddler/screens/validation.ex, the pressed-button lookup defaultingvalidatesto true). The keyless-button edge the third amendment names is still separately reported asdocument.invalid_key(lib/riddler/screens/document.ex). - The
@doconvalidate_screen/4and the matching passage inREADME.mdboth still state the opt-out as the amendments say they do, and the schema's own description still reads as the second amendment quotes it, under the name the note above gives it,priv/schemas/screen-document.schema.json.
Two deferrals the amendments recorded have since been taken up or still stand,
and neither changes what is accepted here. The corpus case pair the third
amendment left "for the corpus pass" - a non-validating press and a validating
press on one screen carrying a condition the root cannot decide - is in
corpus/screens/validate_responses.json, stating exactly the rule that
amendment decides. The admit-side case for document.invalid_pattern that the
second amendment left for the same pass is not there yet; the response-validation
case whose pattern is [0-9 states ok: true, as that amendment says it now
must.
One reading is recorded because it could have gone the other way. A separate
open defect has Riddler.Template.compile/1 raising on some malformed input,
which sits under ADR-0003's compile contract. It does not bear on the second
amendment: the expression that amendment holds a document to is the pattern
format's, compiled by the one private regular-expression compiler in
Riddler.Screens.Validation, which answers :error rather than raising and
never reaches the template compiler. The two compile paths are distinct, and the
amendment's claim survives.
Amendment, 2026-09-18: a keyless button cannot opt out, and a call naming no button never does
Status: accepted (2026-09-19)
Recorded 2026-09-18, campaign RF055, bead rd-ncs. The amendment above, on the
per-button opt-out, names one case and declines it in these words: "Whether a
keyless button should be able to opt out at all is left open exactly as this
amendment found it." This amendment closes that question, and closes it the
other way from the behaviour the package ships. Every cite below is read at
543f35275f05f735a34e88777201fa2e4beb1016. The code half is a separate bead
and a separate commit; nothing here is citable from lib/ until it lands.
What the record now decides
validates as false is meaningful only on a button that carries a key,
and only to a press that names that key. The opt-out is a property of a
press, not of a node sitting on a screen. A button with no key cannot opt out
of anything, because nothing can name it and so no press can arrive through
it.
Riddler.Screens.validate_screen/3 never opts out. It names no pressed
button, so there is no press to read validates from, and it validates the
resolved screen in full. The same holds of validate_screen/4 handed a
pressed key of nil: an absent pressed key names no button, whatever nodes
the screen carries.
What the package does today is the other reading, and this amendment is the
reason to change it. Riddler.Screens.Validation finds the pressed button with
node[:type] == "button" and node[:key] == key
(lib/riddler/screens/validation.ex:101, the private button?/2, read at
543f35275f05f735a34e88777201fa2e4beb1016), and the arity-3 clause passes a
pressed key of nil (lib/riddler/screens.ex:305 and :306, read at the same
SHA). A button node carrying no key at all reads node[:key] as nil too,
so the two absences compare equal, the private opted_out?/2
(lib/riddler/screens/validation.ex:94 through :99, same SHA) answers true,
and the call answers :ok with every finding on the screen silenced at once
(lib/riddler/screens/validation.ex:39 through :41, same SHA, where the
opt-out stands in front of the checks).
The case is narrower than a document carrying such a button, and the rule
above is what it is measured against rather than a count of the calls it
reaches. opted_out?/2 is handed one resolved screen, so a validation of any
other screen of the same document is untouched. It takes the first node the
match finds, so a keyless button earlier on the screen carrying the default
hides a later one declaring false. And a button whose own condition the root
decides false is dropped from the resolved screen before validation is reached
at all (lib/riddler/screens.ex:382 and :397, same SHA), so it declares
nothing. What is fail-open is an arity-3 validation of a resolved screen whose
first keyless button node declares validates as false.
That is a defect, and the rule above is what makes it one. The code half
pins the rule with a test that is red before its change and green after: the
arity-3 call, against a document whose screen carries a button declaring
validates as false and no key, reports the screen's findings rather than
:ok. Nothing here enumerates the sites that implement the rule as a complete
list; the rule is what is decided, and the test is what holds the package to it.
Why the fail-closed reading is the only one this record admits
Two absences comparing equal is not a host naming a button. The amendment
above states where validates is read from: "validates is read from the
button the pressed key names on the resolved screen". A call passing nil
names nothing. That it nonetheless finds a node is an artefact of how a missing
key and a missing argument are both spelled, not a host saying which button the
visitor pressed.
The purpose the record gives the opt-out does not reach this case. The
Decision above says what the opt-out is for - "the opt-out is what lets a Back
button leave a half-filled screen without an error" - and the amendment above
builds its whole argument on that: "A Back press is the visitor's, and it is
their navigation rather than their submission." A button with no key is not a
Back button anyone pressed. Nobody left the screen through it, because nobody
could. The reason the amendment above gives for answering :ok is simply
absent here, and with it the answer.
Answering :ok here is the behaviour the first amendment removed. The reason
that amendment gives for the finding is carried in the package's own words:
treating the node as hidden and answering :ok "would accept a submission
nobody checked" (lib/riddler/screens.ex:252 through :254, the @doc on
validate_screen/3, read at 543f35275f05f735a34e88777201fa2e4beb1016); the
amendment above names the same removed behaviour as "a submission accepted
unchecked". That amendment carved one case out of the rule, and the carve-out
held because a non-validating press accepts no submission. The
arity-3 call is not a press at all: it is a host asking whether a set of
responses is good. Answering :ok to that question, on the strength of a node
nobody pressed, is exactly the accepted-unchecked submission the record refuses
- and it is worse than the case the first amendment found, because it silences every finding on the screen rather than one.
The document being defective does not make the answer safe. A button with no
key is reported as document.invalid_key, from the keyless clause of the
private key_findings/2 (lib/riddler/screens/document.ex:495 through :503,
read at the same SHA), reached through Riddler.Screens.Document.validate/1.
That is a different door. A host that validates responses without having walked
the document door - which this package admits, admit/1 and validate/1 being
separate calls - is told nothing, and is told :ok. The record's own framing
of admit findings is that a document carrying one is still a document; a rule
that is only safe for hosts who checked is not a rule this record can rest a
silent :ok on.
The screen validated is the screen shown. The first amendment's rule is that a rule stated about the screen the visitor was shown holds of the screen validated. A keyless button is on the resolved screen and can be rendered; what it cannot do is be pressed, because a press is a key. Reading it as the pressed button makes the validated screen behave as though a press arrived that the shown screen had no way to send.
Why an amendment and not a note
The test this record states for itself is whether an entry changes what the
record decides, and the counterweight it states just as plainly is that a
decided reading "can remain a note because its decided reading takes nothing
away". This one takes something away: an :ok the package answers today, on a
call the amendment above describes in its own words as answering :ok. A
reader of the record as it stands would write a host against that sentence. So
this is not the metadata requiredness case, where deciding the open reading
changed nothing the record had decided.
It also bears on the amendment above as directly as an entry can, being a qualification of the rule that amendment states, which the record names elsewhere as the mark of an amendment rather than a note. The other test this record uses - the uncompilable-pattern amendment's "a new refusal of documents 0.1.0 admits" - is not met and does not need to be: no document is refused here, and that test is a sufficient reason for an amendment rather than a necessary one.
What is unchanged
Everything the three amendments above list as unchanged, and each of those
amendments apart from the one question this entry closes for the third of them.
validates keeps its default of true, its meaning on a keyed button, and its
admit-time shape check. A press through a keyed button declaring false still
answers :ok without running a check, for an undecidable condition and for
every other finding, exactly as the amendment above decides. A key naming no
button on the resolved screen still validates, because validating is what a
button that is not there carries. resolve/2 and resolve_screen/3 are not
touched, and their diagnostics are what they were.
No line of this record is edited by this amendment. The paragraph in the amendment above that describes a call naming no button as "treated as a press through it" stays exactly as written. It is a true description of what the package did when it was written and of why that amendment declined the question; a record shows its reasoning as it went, and this entry adds the answer at the foot rather than rewriting the paragraph that posed it.
One sentence of the public documentation becomes false and is edited by the
code half, not here. The @doc on Riddler.Screens.validate_screen/3
carries the exception as a live rule - a call through the arity-3 form reports
the finding "unless the screen carries a button declaring validates as
false and no key at all" (lib/riddler/screens.ex:257 through :261, read
at 543f35275f05f735a34e88777201fa2e4beb1016). Under this amendment there is
no such exception. The bead that changes the behaviour changes that sentence in
the same commit. The @doc on validate_screen/4 and the matching passage in
README.md need no change: both state the opt-out for a button the press
names, which is what it now is.
The conformance corpus states nothing this amendment changes. Every button
in corpus/screens/validate_responses.json carries a key (read at
543f35275f05f735a34e88777201fa2e4beb1016), so no case presses, or fails to
press, a keyless one. A case pinning this rule from the corpus side - the
arity-3 capability against a screen carrying a keyless non-validating button -
is left for the corpus pass.
One edge stays where the amendment above left it. Whether a press through a button hidden by its own undecidable condition should reach the opt-out is still unanswered, and this amendment does not reach it: that button carries a key, and the question there is about resolution rather than about naming.
Noted 2026-09-18, campaign RF055, beads rd-0yg, rd-uq9 and rd-pli. Three notes
by addition, each read against main at 9e5d667. They narrow a claim an
amendment above makes about how far its own change reaches, say where the four
places the acceptance note lists as not yet lined up are now answered, and
record what a validation call does with the diagnostics of the resolution it
performs. None of them changes what this record decides, and none bears on the
amendment above.
Three of the four things the first 2026-09-17 amendment names changed with
resolve_screen/3's return, and one did not (rd-0yg). That amendment's
section "A single-screen call returns its diagnostics" names four - that the
call is public, that it is "documented with executable examples that match on
{:ok, screen}", that it is "cited by ADR-0001 as the call that resolves one
named screen", and that it is "called inside this package by the corpus runner"
- and then says "Every one of those changes with the return." That holds of the
code and not of the record. In the one commit that carried the return
(
fc69b8d), what changed wasresolve_screen/3's@specand body and the one@docexample that matched{:ok, screen}, which now matches{:ok, screen, diagnostics}and asserts the diagnostics beside the screen (lib/riddler/screens.ex, theresolve_screen/3@doc,@specand clause, read at9e5d667); the second example in the same@doc, which matches{:error, :no_such_screen}, was untouched, that arm being unchanged. The corpus runner's call changed too: it matches{:ok, screen, _diagnostics}and still encodes the screen alone (lib/riddler/corpus.ex, the"screens.resolve"clause, read at9e5d667). The list names one in-package caller and there are two:validate_screen/4moved with the return in that same commit, matching{:ok, screen, _diagnostics}atfc69b8dwhere it had matched{:ok, screen}, while its own return was untouched. It binds those diagnostics rather than dropping them today, which the third note below records (lib/riddler/screens.ex, thevalidate_screen/4clause, read at9e5d667). What is enumerated here is what moved with the return, not every line that commit touched: it also added a sentence to this module's@moduledocand a paragraph toresolve_screen/3's@doc, both stating the new return. ADR-0001 did not change with it, and the two citations it carried then had nothing to change: that record named the call once as the function that "resolves one named screen of it" and once in a provenance line naming the request that built it besideresolve/2, and neither sentence states a return shape. That commit touchedlib/riddler/screens.ex,lib/riddler/corpus.ex, two test files and a changelog fragment, and no file underdocs/adr/. What ADR-0001 needed was an entry of its own rather than a correction, and it has one: a dated note of 2026-09-18 on that record, which landed after the tree this entry was otherwise read against and is read here ata72788a, draws the distinction as "Which call resolves one named screen is unchanged; what that call returns is not what it was when this record was accepted." The first of the four things listed is the call's own public surface, and that did change: its@specstates the return, and its being public is why the change is breaking rather than internal. This record's own earlier sentences namingresolve_screen/3are unchanged as well, and could not be otherwise: this record grows by addition, so an amendment governs over them where it says it does and nothing above it is reworded.
The four places the acceptance note lists as not yet lined up are each answered further down, and the list itself still reads as open (rd-uq9). The 2026-09-15 acceptance note above opens that list "Four places where this record and the code it describes do not yet line up are carried as notes by addition rather than as corrections, each with a bead". Four is still the count at this reading, and all four are answered below it. Nothing removes the list, because this record grows by addition, so this note is the pointer the list cannot carry itself. In the order the list gives them:
- The envelope shapes "stated here and not yet checked by
validate/1" (rd-xxb) are answered by the entry opening "Noted 2026-09-17, campaign RF051, bead rd-xxb.", whose one note is headed "The envelope and boolean shapes this record states are checked, one code each, and only where the document carries the field." - The unreachable variant candidate, "which this record describes as dead weight without saying it is refused" (rd-d8e), is answered by the note headed "An unconditional variant candidate that is not last is an admit finding." in the entry opening "Noted 2026-09-17, campaign RF051, beads rd-gwn, rd-d8e, rd-xvv and rd-xva."
resolve_screen/3returning "a screen without the diagnostics its resolution produced, which this record does not decide either way" (rd-439) is decided by the amendment headed "Amendment, 2026-09-17: the screen validated is the screen shown", in its section "A single-screen call returns its diagnostics", which answers "It returns them."metadata'sname,descriptionanddomain, "named here as what the block carries without a requiredness rule" (rd-xvv), are answered by the note headed "None ofmetadata'sname,descriptionanddomainis required." in that same four-note entry of 2026-09-17.
Where a later entry answers an item on that list, that entry is where the answer now is; the list stands as the account of what was open on 2026-09-15.
A validation call returns no diagnostics, and the two halves of the
diagnostics its resolution produced go different ways (rd-pli).
Riddler.Screens.validate_screen/3 and /4 answer
:ok | {:error, [Finding.t()]} | {:error, :no_such_screen}
(lib/riddler/screens.ex, both @specs, read at 9e5d667), and the arity-3
form is the arity-4 form called with no button named. Neither return carries
missing_variables or undecidable_conditions. That is not a discard. The
arity-4 body resolves through resolve_screen/3, takes the diagnostics beside
the screen and hands both to the check (lib/riddler/screens.ex, the
validate_screen/4 clause, read at 9e5d667), which turns each undecidable
condition into a response.undecidable finding on the node carrying it, with
field "condition", and never reads missing_variables at all
(lib/riddler/screens/validation.ex, validate/4 and the private
undecidable_findings/1, read at 9e5d667). A press that opted out reaches
neither half and answers :ok, which is the qualification the third 2026-09-17
amendment records. That amendment's "What is unchanged" states the other half -
"missing_variables still becomes no finding" - and the reason is the lenient
rendering this record already decides: a variable a template wanted and the root
did not carry renders as the empty string, which the @doc on
validate_screen/3 puts as "So is a variable a template wanted and the root did
not carry - that renders as the empty string, is reported in
missing_variables, and is no finding." Only a condition decides whether a
question was asked at all, and only that half bears on whether a visitor can be
held to an answer. So the decided reading is that a call returning no screen
surfaces the undecidable half as findings and lets the missing-variable half go
unreported, and a caller who wants a screen's diagnostics asks
resolve_screen/3 for them, that being the call this record decides returns
them. This note states the contract the package ships and changes nothing.
Noted 2026-09-18, campaign RF055, beads rd-cay, rd-qr5 and rd-x6t. Three notes
by addition, read against main at 474a963. Two of them settle and then
record the one case the pattern amendment above leaves with response
validation; the third re-states where a phrase the note above cites first
appears. None of them changes what this record decides, and none changes what
any amendment above decides.
A format declared without the parameter it reads stays a response-time
finding (rd-cay). The amendment above headed "an uncompilable pattern is the
document's defect" closes its section "One case stays with response validation"
on a question it declines: "Whether a format declared without the parameter it
reads should itself be a document finding is a question this amendment does not
decide." It is decided here, and the answer is no. A question that declares
format as pattern and declares no pattern at all raises nothing at admit,
exactly as that section describes, and response validation is where it is
reported. The reason is the mark that amendment sets for itself: a new admit
finding here would refuse documents this version admits, and would refuse them
for a defect the response-time clause already reports, with the field naming
the parameter the question left out. What is decided is only which layer
reports the defect, and that is the layer reporting it today. The decided
reading takes nothing away - no document that validates clean stops doing so,
no finding is renamed or moved, and no new code is named - so this is a note
and not an amendment, on the test the note above states when it declines to
name a code for a question this record leaves open: doing so "would change what
this record decides, and that is an amendment's work, not a note's". A recorded
no names no code and changes nothing this record decided.
The finding that one case carries was reworded, and this is what it now says
(rd-qr5). The move the amendment above records leaves response validation
answering a single case on the pattern field, and the wording that case
inherited described a pattern as unusable where there is no pattern to be
unusable. The change that carried the move reworded it. The finding is
response.format, its field is pattern, and its message reads, character
for character:
this question asks for a response in the form of a pattern and declares none, so nothing can satisfy it
(lib/riddler/screens/validation.ex, the private pattern_finding/1, read at
474a963). The amendment above says response validation "answers
response.format with the field pattern", and says nothing about the
message; this note is where that text is recorded, because a host may match on
a message rather than on a code and this record is where a host reads what a
finding means. The field is what marks this case out rather than the code:
response.format is still what every other format check raises, and those
carry the field format (the same file, the private format_finding/2, read
at 474a963), so the only response finding naming the field pattern is this
one. Nothing here changes a finding, a code, a field or a message.
Where "schema-valid means admitted" first appears, and who adopts it
(rd-x6t). The note above of 2026-09-17 closes its third bullet by calling
that phrase "the conclusion that passage draws from it in its own words, that
schema-valid means admitted". The attribution points one step downstream of
where the phrase originates. It is the document schema description's own
closing words, which read "A validator holding this schema and a runtime
admitting the same value therefore agree: schema-valid means admitted."
(priv/schemas/screen-document.schema.json, read at 474a963; the same
sentence closes that description under the file's earlier name at both SHAs the
bullets around it cite, 342c790 and 27f9d91). This record adopts the phrase
rather than coining it, and does so twice. The passage above beginning "The
schema is the fourth reading and it is wider still" attributes it where it
belongs, as "the thing the schema's own description already says"; the
amendment above, under "What the record now decides", carries it forward as
"so that schema-valid means admitted". So the chain reads: the schema's
description states it, two passages of this record adopt it, and the note above
cites the second of those two. Nothing that note presents as a quotation is
inexact and its substance stands; what is corrected is the one clause of
attribution, stated here once rather than adjusted again.
Noted 2026-09-18, campaign RF055, beads rd-5v2, rd-6kl and rd-hky. Three notes
by addition, read against main at 0214837. The first two decide the two
questions the note above on shape checks carries as open, and decide both by
recording what this record and the code already do; the third says which block
an earlier reference names. None of them changes what this record decides, and
none changes what any amendment above decides.
None of the schema version, the document id and a screen's title is required
(rd-5v2). The note above headed "Each is a shape check and none of them is a
requiredness check" states the reading and carries the question open. It is
decided here, and the answer is that none of the three is required: a document
that omits any of them, or all three, is admitted and validates clean, exactly
as it does today. Three surfaces say so, and none of them is changed. The
schema requires screens of a document and nodes of a screen and nothing
else (priv/schemas/screen-document.schema.json, read at 0214837). The
runtime's three checks each answer an absent field with no finding, by
matching nil in a clause head before they judge anything - the private
schema_version_findings/1, id_findings/1 and title_findings/1 in
lib/riddler/screens/document.ex, read at 0214837, the first two reached
through envelope_findings/1 and the third through screen_findings/1. And
this record, from this note's date, says the same rather than leaving the
question to be inferred from the other two. What the note above gives as its
reason for stopping is the reason for the answer: requiring a field this
record names would refuse documents riddler 0.1.0 and 0.2.0 admit, and a new
refusal is an amendment's work. A recorded no takes nothing away, names no
code and moves no finding, so this is a note. What it settles is that the
three surfaces now agree about presence as well as about shape - absent is
admitted, present is shape-checked - and a host that needs an id or a schema
version present enforces that itself, or holds the document to the schema.
An explicit JSON null is admitted on the three envelope and screen fields
and refused on the three node fields, and both arms stay (rd-6kl). The note
above states that split and carries open the question of which of the two
answers should hold for all six. It is decided here, and the decision is that
neither arm moves. The split was probed again rather than reasoned about, one
document per field through Riddler.Screens.Document.admit/1 and then
validate/1 at 0214837: "schema_version": null, "id": null and a
screen's "title": null each validate clean, and "required": null,
"validates": null and "style": null each raise exactly one finding,
document.invalid_required, document.invalid_validates and
document.invalid_style respectively. The difference is the accessor and
nothing else. The three node checks read the field with Map.fetch/2 and so
see {:ok, nil}, which is not what an absent key gives them (the private
required_findings/1 in lib/riddler/screens/type/text_question.ex, and the
private validates_findings/1 and style_findings/1 in
lib/riddler/screens/type/button.ex, all read at 0214837); the envelope and
title checks match nil in a clause head and cannot tell a declared null
from an omission.
Each arm stays for its own reason, and in neither case is the reason that the
split is tidy. Refusing a null on the envelope and screen fields would mean
admit/1 recording absence separately from nil - a change to the admitted
struct, and a new refusal of documents 0.2.0 admits. Accepting a null on the
three node fields would remove three findings 0.2.0 ships. Each arm is
therefore the conservative one for the fields it governs, and recording them
is what this note does rather than making them agree.
The schema is stricter than the runtime on one half of the six and silent on
the other, which is worth stating because it is not what a reader expecting
the schema to be uniformly stricter would find. It types id and
schema_version and a screen's title, so an explicit null on any of those
three is not schema-valid and the runtime admits there what the schema
refuses. It does not describe required, validates or style at all - the
node definition names condition, key, nodes and type, and does not
close the object - so an explicit null on those three is schema-valid and
the finding is the runtime's alone (priv/schemas/screen-document.schema.json,
read at 0214837). The schema stays what its own description says it is, the
statement of "exactly what makes a JSON value a screen document at all"; a
host that wants the envelope held to the types this record states validates
against the schema before it calls the runtime.
Which block the opt-out amendment's single-note reference names (rd-hky).
The amendment above headed "the per-button opt-out covers an undecidable
condition" writes, under "Why an amendment and not a note", the sentence "The
single-note block, of its own note:" and gives after it the quotation "it
changes nothing this record decides, and it does not bear on the amendment
above." Two single-note blocks stand above that reference, so its description
does not pick out one of them on its own. The block it names is the one
opening "Noted 2026-09-17, campaign RF051, bead rd-xxb. One note by addition,
read against main at 342c790." The quotation that follows the description
resolves uniquely: the other single-note block, the one opening "Noted
2026-09-17, campaign RF051, bead rd-mnp. One note by addition, read against
main at 4208433.", accounts for itself in different words, as recording
"nothing new about the document" and as changing "nothing either amendment
above decides". Nothing in that section's argument moves, and the
generalisation it draws holds of both blocks; no citation in it fails. What
is stated here is only which block it meant.
Noted 2026-09-18, campaign RF055, bead rd-7lu. One note by addition, read
against main at 3ff9a42. It records a naming decision and what follows from
it for a host, and changes no answer this record gave.
response.undecidable keeps its response. prefix by decision, and this one
is the recorded exception rather than the pattern. The question the code's
name raised was whether it should move to a prefix naming the call, since the
finding is not about a response. It does not move, and the reason is that it is
published. The code string ships in riddler 0.2.0
(lib/riddler/screens/validation.ex, the private undecidable_finding/1, read
at the tag v0.2.0, a3ee6e6), where the @doc on
Riddler.Screens.validate_screen/3 lists it beside its three siblings and calls
a finding's code "a stable code". It is also the expected code of three
cases in the conformance corpus, in corpus/screens/validate_responses.json,
among them the case named "A condition the root cannot decide is a finding, not
a silent pass: the question is neither held to its requirement nor let through"
and the case named "The validating button on that same screen returns the
finding the root could not decide" (read at 3ff9a42; those two carry the code
at the tag v0.2.0 as well, the third having been added since). A rename would
change a published finding code and restate what those cases assert, and would
buy a name and nothing else: no document would be admitted or refused
differently, and no host would read a finding it does not read today. The next
finding of this kind - one about the call rather than about the visitor - takes
a prefix naming the call when it is first written, while naming it is free.
What the prefix does not tell a host. A host that matches on the response.
prefix to decide that a finding belongs beside the field the visitor is editing
will pick this one up with the rest, and nothing the visitor types clears it: a
condition that could not be decided reports that the root the host handed in
does not carry what the document asks about, which the @doc above puts as "a
defect in the call rather than a property of the visitor". Its field is
"condition" rather than a field of the question
(lib/riddler/screens/validation.ex, undecidable_finding/1, read at
3ff9a42), and the passage of this record beginning "A validation call returns
no diagnostics" says where it comes from. A host that wants that placement
matches the code in full rather than its prefix.
The siblings are not uniformly about the visitor either, so the prefix carries
less than it looks like it carries. The three listed beside it in that @doc
are response.required, response.format and response.out_of_range. Two of
them are about what the visitor typed in every case they raise: required is
unanswered when the response is absent or is a string of whitespace, and
out_of_range is a number outside a bound the question declares
(lib/riddler/screens/validation.ex, the private required_finding/1 and
range_finding/5, read at 3ff9a42). response.format is about what the
visitor typed in every case but one: the case the note above headed "The finding
that one case carries was reworded, and this is what it now says" records, whose
field is pattern and which fires for a question declaring the pattern
format and declaring no pattern at all - a defect in the document, which no
response can satisfy. So the statement this record makes is the negative one: a
response. code does not by itself say that what the visitor typed is what the
finding is about, and two codes in that set already show it.
Why a note and not an amendment. The text above this entry decides nothing about what a code's prefix means; it names codes one at a time and says of each what it reports. So recording that the prefix does not partition findings by who can clear them contradicts no rule stated above and takes nothing away: no document that validates clean stops doing so, no code, field or message moves, and no new refusal is added. A recorded decision to leave a published name where it is changes no answer this record gave.
Noted 2026-09-18, campaign RF055, bead rd-d9n. Three notes by addition, read
against main at 90f5930, with the code half that makes the first two true
in the same request at b67dea5. Nothing above is changed; each paragraph
below says what the text above means now.
The three things that leave a condition undecided are one finding, by
decision. The amendment above headed "the screen validated is the screen
shown", in its section headed "An undecidable condition is a finding", decides
that a condition that cannot be decided against the root answers a finding
rather than :ok, and leaves "the finding's code to the bead that raises it".
The code that was raised, response.undecidable, is reached by
three unrelated things: a condition that compiles and that this root leaves
undecided, a condition that is not valid predicator at all, and a condition
that is not a string (lib/riddler/screens/validation.ex, the private
cause/2, read at b67dea5). It stays one code for all three, and the reason
is fail-closed rather than tidy. The second and the third are each already a
document finding, document.invalid_condition on the same node and the same
field (lib/riddler/screens/document.ex, the private condition_findings/2
and compile_condition/2, read at b67dea5), and that remains where an author
fixing the document is told about them. But nothing obliges a host to call
Riddler.Screens.Document.validate/1 before it resolves, and a host that does
not is the one this check stands for: narrowing the code to the
genuinely-undecided case would let a document the parser refused resolve and
validate clean, which is the silent pass the amendment above removes. What
tells the three apart is the message, which now names which of them fired.
The code, the field "condition" and the node_key are what they were, so
a host switching on the code reads what it read before
(lib/riddler/screens/validation.ex, the private undecidable_finding/2, read
at b67dea5; the three are pinned one case apiece in
test/riddler/screens/validation_test.exs, the describe block "which of the
three things left a condition undecided", read at b67dea5).
It carries the place it is given, and invents none. The rule is not one
about the three causes, and it is worth stating on its own because the causes
do not settle it: the finding carries a place exactly when the parser or the
evaluator hands one back, and nil otherwise. A condition that is not valid
predicator is always located, because the refusal the parser answers with
carries a position as a field it requires. A condition that is not a string
never reached the parser and is never located. A condition that compiles falls
both ways, and which way is a property of the error the evaluator answers
rather than of the cause. The inputs driven against this code half at
b67dea5, under a root of %{"context" => %{"n" => 5}, "responses" => %{}},
are examples of both and not an enumeration of them: "is_business" and
"nosuch(1)" come back located at line 1, column 1 and "1 / 0 > 1" at line
1, column 3, the evaluator answering an error that carries a position;
"context.is_business == true" and "context.n > 'a'" come back
{:ok, :undefined} and "context.n + 1" comes back {:ok, 6}, which are not
errors and carry no place, so the finding carries none. What decides the field
is therefore the shape the dependency answers with, and of the five error
structs predicator 9.4.1 defines, one requires a position, three carry it as
an optional field that may be nil, and one has no such field at all. The
finding takes it through Riddler.Finding.position/2, which builds the map
only from a positive line and a positive column and answers nil for anything
else (lib/riddler/finding.ex, position/2 and its fallback clause, read at
b67dea5). The message and the field cannot disagree about whether there is
one: the place is appended to the sentence from the position rather than from
the numbers it was built out of
(lib/riddler/screens/validation.ex, the private span/1, read at
b67dea5). Reading the condition a second time is what makes this possible,
and the validation entry point is handed the whole root rather than the
responses inside it so that the second reading runs against the same root the
first did (lib/riddler/screens.ex, the validate_screen/4 clause, and
lib/riddler/screens/validation.ex, validate/4, both read at b67dea5). The
published diagnostics gain nothing: undecidable_conditions is still a list of
%{key: ..., condition: ...}, which is what the Typespecs section above states
and what resolve_screen/3 answers, and a key added to it would be a new
public field for something only the response check reads.
A count in ADR-0001 moves with this, and belongs to that record. The note
in docs/adr/0001-riddler-one-package.md headed "Riddler.Finding carries a
source position, and this record is where that is decided" says under "Which
findings carry it" that three sites in lib/ set the field and no others, and
names response.undecidable among the findings that leave it nil; the
amendment below it headed "a placeless parse refusal is a finding with a nil
position" restates that count as still three. After the change recorded above
there are four sites, and response.undecidable is the fourth. That record
also files the nil this change removes as one of three defects recorded
"rather than explained", on the ground that "a record cannot be made true about
behaviour that is wrong", so the count moving is what it anticipated rather
than something it decided against. Saying so is all this paragraph does:
ADR-0001 is where its own count is stated and where it is restated.
Why a note and not an amendment. The test the records state in their own
words is whether the entry changes an answer the record gave: an entry is an
amendment where "the rule stated above answers a call one way, and this entry
answers the same call another". No rule stated above answers a call one way and
is answered another here. This record decides that an undecidable condition is
a finding on the node carrying it, with field "condition", reported wherever
the pressed button validates, and every one of those is what it was: no
document that validates clean stops doing so, no document that is refused stops
being refused, no code, field or node key moves, and no refusal is added. That
the catch-all was left as it stands is a recorded decision to leave behaviour
where it is, which the records treat as a note, and deciding a question the
record left open is not the test either: this record already says so, in the
sentence "Deciding an open question and changing what the record decides come
apart, and it is the second that governs." What is decided above stays a note
on the same ground the note above that sentence gives for itself, that "its
decided reading takes nothing away, and so changes nothing this record had
decided". One thing this entry does move is the
message, which the single-note block above it names among what it left where
it was; a message is not part of what this record decides, which names codes
and fields and never a sentence, and the block above accounts for its own entry
rather than for this one.
Noted 2026-09-18, campaign RF055, bead rd-7ue. One note by addition, read
against main at 26b52cc. It answers the one edge the amendment above names
as left open, and answers it by recording what this record already decides and
what the package already does. Nothing above is changed, and no answer this
record gave moves.
A button whose own condition the root could not decide is not on the resolved
screen, so a press naming its key validates that screen in full. Two
amendments above leave this edge open in their own words, and both say so in
their "What is unchanged" sections. The amendment headed "the per-button
opt-out covers an undecidable condition", whose Status: line reads
accepted (2026-09-18), carries there a paragraph opening "One edge stays
exactly where the amendment above left it, and this amendment decides nothing
about it." and closing "Whether a press through a button hidden by its own
undecidable condition should reach the opt-out this amendment carves out is a
question this amendment does not answer." The amendment headed "a keyless
button cannot opt out, and a call naming no button never does", whose Status:
line reads proposed, ends its own section with the same edge in different
words: "Whether a press through a button hidden by its own undecidable
condition should reach the opt-out is still unanswered, and this amendment does
not reach it: that button carries a key, and the question there is about
resolution rather than about naming."
The answer is that the press does not reach the opt-out. What happens is
already written down, between those two sentences of the accepted amendment:
"A button's own condition may be the undecidable one. Resolution then drops
that button from the screen, so nothing on the resolved screen declares
validates at all, and the press falls to the default: it validates, and the
finding is raised." What that amendment declines is only whether that is what
should happen, and the rule that settles it is the one it states itself, under
"What the record now decides", about where validates is read from -
"validates is read from the button the pressed key names on the resolved
screen" - together with what the same passage says of a press that names none:
"A press through a button that says nothing, and a press through a key naming
no button on the resolved screen, both still report it." A button the document
says may not be shown is not on the resolved screen, so the press names no
button there. It is the second of those two cases and not the first, and the
opt-out is not weighed and refused so much as absent: there is no node for it
to read validates from.
What the package answers, driven at this reading. The screen the test below
uses is a signup screen carrying, among its nodes, two required questions
keyed first_name and email and a Continue button, given in addition a Back
button keyed account_back that declares validates as false and carries a
condition of its own, context.is_business == true, under a root that carries
no context at all. Resolution drops the Back button:
Riddler.Screens.resolve_screen/3 answers a screen whose node keys do not
include it, and reports the condition in its diagnostics as
%{key: "account_back", condition: "context.is_business == true"}. The keyed
press of account_back, with first_name blank and email absent, answers
{:error, _} with three findings in this order - response.undecidable on
account_back, response.required on first_name, response.required on
email - which is the same list, finding for finding, that a press of the
Continue button on that screen answers. The first finding's field is
"condition" and its message reads, character for character:
the condition "context.is_business == true" could not be decided against the root this screen was validated with, so whether this question was asked of the visitor is not established
That is pinned by the test named "does not opt its keyed press out, because the
press names no button that is there", in the describe block "a button hidden by
its own undecidable condition" of
test/riddler/screens/validation_test.exs.
Where in the code the two halves meet, and why the drop comes first.
Resolution decides a node's condition before anything validates, and for a node
sitting directly on the screen, which is the node this entry and its test are
about, one path down through lib/riddler/screens.ex does it. resolve_one/3
hands the screen's nodes to resolve_nodes/3; resolve_nodes/3 asks shown/3
about each node in turn; shown/3 asks decide/3; decide/3 answers
{true, diagnostics} for a node carrying no condition, and otherwise calls
evaluate/4 with the condition and the node's key; and evaluate/4 is where a
condition that could not be decided answers false while recording the condition
through undecidable/3. That recording is the _undecidable -> arm of the
case inside evaluate/4's clause for a condition that is a binary, which
answers {false, undecidable(diagnostics, key, condition)}; the other
evaluate/4 clause, for a condition that is not a binary, answers the same
pair without a case. shown/3 turns that false into {nil, diagnostics},
which is all it does with it. resolve_nodes/3 is what leaves the node out:
reducing over the screen's nodes, its {nil, diagnostics} arm answers
{acc, diagnostics} - the accumulator it was handed, unchanged, beside the
diagnostics shown/3 answered with, which in this case are the updated ones
carrying the recorded condition - so the node never enters the list the
resolved screen carries. A node reached as a variant's candidate takes a second
route to the same place: decide/3's other caller is winner/3, which walks a
variant's candidates in order, and a candidate whose condition is undecidable
takes the false arm there too, so winner/3 passes it over and considers the
next with the diagnostics decide/3 just updated - driven at this reading, a
variant whose first candidate is a button declaring validates as false with
the condition context.is_business == true and whose second is an
unconditional Continue button resolves, under the root above, to the node keys
["first_name", "account_continue"] with undecidable_conditions carrying
%{key: "account_back", condition: "context.is_business == true"}, and the
keyed press of account_back answers the findings a press of
account_continue answers, so the condition is recorded, the candidate is not
on the resolved screen, and the press validates in full exactly as above. Every
function named so far in this paragraph is private, sits in
lib/riddler/screens.ex, and is read at 26b52cc. Validation is then handed
that resolved screen, and the opt-out searches its nodes alone for a node that
is a button and whose key equals the pressed key
(lib/riddler/screens/validation.ex, the private opted_out?/2 and
button?/2, read at 26b52cc). A dropped button is not among them, so the
search finds nothing and the call takes the validating arm. The public
documentation already states the consequence without naming this route to it:
the @doc on Riddler.Screens.validate_screen/4 says "A key that names no
button on the resolved screen validates too, because the default is what a
button that is not there carries." (lib/riddler/screens.ex, read at
26b52cc).
Why the carve-out's reasoning does not reach this case. The amendment that
made the carve-out argues from who is charged for a defect: "A defect in the
call is the host's. A Back press is the visitor's, and it is their navigation
rather than their submission." That argument is about which findings a
navigating visitor should be held at the screen by, and it presumes a door the
document offers. Here the document says the door may not be shown, or rather
says nothing decidable about whether it may: the condition the host's root
could not decide is the button's own, so whether the visitor was ever offered
that Back button is exactly what is not established. Answering :ok on the
strength of a node the visitor cannot be shown to have been able to press
would silence every finding on the screen on the say-so of a condition nobody
could decide, which is a wider :ok than the carve-out ever granted. The host
is told what it needs in the same breath: the undecidable condition is reported
as a finding on the button, and reported again in the diagnostics of every
resolution of that screen whatever is pressed. The condition is the defect to
fix, and fixing it restores the Back button and with it the opt-out.
Why a note and not an amendment. The test these records state for
themselves is whether an entry changes an answer the record gave, and deciding
a question the record left open is not that test: "Deciding an open question
and changing what the record decides come apart, and it is the second that
governs." This record had left this question open and said so twice, in the two
sentences quoted at the head of this entry, so there is no answer above to
change. Two sentences above could nonetheless be read as reaching this case,
and each is governed by a disclaimer in the very amendment that carries it. The
amendment headed "the per-button opt-out covers an undecidable condition" opens
"What the record now decides" with "A button declaring validates as false
answers :ok for the screen it submits even when that screen carries a
condition the root could not decide", and qualifies that sentence in the
paragraph below it with where validates is read from; the same amendment's
"What is unchanged" section says of this edge that "this amendment decides
nothing about it". The amendment headed "a keyless button cannot opt out, and a
call naming no button never does" lists among what is unchanged that "A press
through a keyed button declaring false still answers :ok without running a
check, for an undecidable condition and for every other finding, exactly as the
amendment above decides", and that same amendment's last paragraph says of this
edge that it "is still unanswered, and this amendment does not reach it". A
record cannot both answer a call and say that it does not, and where the two
readings of one entry differ it is the one the entry states
about itself that holds. What is decided here takes nothing away either: no
document that validates clean stops doing so, no document that is refused stops
being refused, no code, field, message or node key moves, no refusal is added,
and no line of lib/ changes with this entry. A recorded decision to leave
behaviour where it stands is what these records treat as a note.
Noted 2026-09-18, campaign RF055, bead rd-ve3. One note by addition, read
against main at c05df20. It records what the amendment above already
decides looks like from the submission side, and changes no answer this record
gave.
An uncompilable pattern is not re-checked at submission, so a host that
does not validate the document accepts any response to that question's pattern
check. The amendment above headed "an uncompilable pattern is the document's
defect", whose Status: line reads accepted (2026-09-18), is what decides
where the defect is reported: "A defect reported from two layers is worse than
one reported late, and the response was never what was wrong: nothing a
visitor could type satisfies an expression that does not compile." Response
validation therefore answers nothing at all for such a question's pattern
(lib/riddler/screens/validation.ex, the private unreadable_pattern/1, read
at c05df20), and the finding a host reads is the document's
document.invalid_pattern (lib/riddler/screens/type/text_question.ex, the
private declared_pattern_findings/2, read at c05df20). A host that
validates responses and not documents runs no check the author's expression
was written to impose.
The decision is kept, and the gap is closed by saying it rather than by
re-checking. The defect is the author's, one layer reports it, and the layer
the record names is document validation; re-checking compilability at
submission would report one defect from two layers, which is the thing the
amendment above refuses. What was missing was that nothing told a host to
look, so the @doc on Riddler.Screens.validate_screen/3 carries it in a
sentence of its own, added by this bead's own commit and so citable at no
earlier SHA. The checks beside the pattern are unaffected: a required
question whose pattern does not compile is still unanswered when its response
is blank, and a sibling question's format still refuses what it refused.
What the package answers, driven at this reading. A checkout screen
carrying one text_question keyed card_field that declares format as
pattern, pattern as [0-9 and required as true, beside a Pay button
declaring no validates. Riddler.Screens.Document.validate/1 answers
{:error, _} with one finding, document.invalid_pattern, whose field is
"pattern", whose node_key is "card_field" and whose message reads,
character for character:
the pattern "[0-9" is not an expression the pattern format can compile, so nothing could satisfy it
Under a root whose responses carry card_field as "not digits at all",
Riddler.Screens.validate_screen/3 answers :ok, and so does the arity-4
form pressing card_pay. Under a root whose card_field is a string of
whitespace, the arity-3 call answers {:error, _} with one finding,
response.required on card_field. That is pinned by the test named "an
uncompilable pattern constrains no response here, and the checks beside it
still run", in the describe block "the formats" of
test/riddler/screens/validation_test.exs. The test above it in that block,
named "a pattern that is not a usable regular expression is the document's
finding, not a response's", pins the boundary itself; this one pins what
follows from it for a host.
Why a note and not an amendment. The test these records state for
themselves is whether an entry changes an answer the record gave, and this
entry gives the same answer the amendment above gives, from the other side of
the same call. Nothing is taken away: no document that validates clean stops
doing so, no document that is refused stops being refused, no code, field,
message or node key moves, no refusal is added, and no behaviour in lib/
changes with this entry.
Amendment, 2026-09-18: a value of the wrong type in a field the schema types is not a document
Status: proposed
Recorded 2026-09-18, campaign RF058, beads rd-f9k, rd-w5i and rd-rp3. The
document schema and the runtime agreed in one direction only: a value the
schema calls a document was admitted, but a value it refuses for holding the
wrong JSON type in a field it types was admitted too, with a finding or with
none. This amendment makes admission refuse what the schema refuses in those
fields, so that the two agree in both directions there, and retires the two
codes that nothing but such a value ever raised. Every cite below of the
package before this change is read at
ef87a50ac596ab3ce5f2ed3138c035626470fc4e. The code half is in the same
request; the functions it adds or changes are cited by name, and none of them
is citable at an earlier SHA.
What the record now decides
A value of the wrong JSON type in a field the document schema types is not
a document. The schema types eight scalars: the envelope's id and kind
as strings and its schema_version as an integer, a screen's key and
title as strings, and a node's key, type and condition as strings
(priv/schemas/screen-document.schema.json, read at
ef87a50ac596ab3ce5f2ed3138c035626470fc4e). A value of any other JSON type
in one of them, null included, is refused by
Riddler.Screens.Document.admit/1, which answers nil for it, as the schema
does. For schema_version the wrong type is a value that is not an integer,
and an integer is what the schema's draft counts as one: a number with no
fractional part, so 1.0 is admitted. A field that is absent is not refused,
exactly as before. The check is the private typed?/2 in
lib/riddler/screens/document.ex, against the three lists of typed fields
beside it.
A nodes on a node whose type reads none is held to the schema's type for
it and then dropped. The schema types nodes on every node as a list of
nodes; the runtime reads it only on variant. On a node of any other type,
known or not, a nodes that is not a list of nodes is not a document, and a
list of nodes - each one a node admission would take - is admitted and dropped
with no finding, as any field a type does not name is (the private
unread_nodes/1 in lib/riddler/screens/document.ex).
What each field answered before and answers now, one document per row, each
put through admit/1 and then validate/1; the two "before" columns are
measured at ef87a50ac596ab3ce5f2ed3138c035626470fc4e, and the schema refuses
every row's input:
| Field | A value of another type, not null | null | Now |
|---|---|---|---|
envelope id | admitted, document.invalid_id | admitted, no finding | not a document |
envelope kind | admitted, document.unknown_kind | admitted as screens, no finding | not a document |
envelope schema_version | admitted, document.invalid_schema_version | admitted, no finding | not a document |
screen key | admitted, document.invalid_key | admitted, document.invalid_key | not a document |
screen title | admitted, document.invalid_title | admitted, no finding | not a document |
node key | admitted, document.invalid_key | admitted, document.invalid_key | not a document |
node type | admitted, document.unknown_type | admitted, document.unknown_type | not a document |
node condition | admitted, document.invalid_condition | admitted, document.invalid_condition | not a document |
nodes on a heading | admitted, no finding | admitted, no finding | not a document |
nodes on a type the registry does not know | admitted, document.unknown_type | admitted, document.unknown_type | not a document |
The value of another type measured in each row: 7 for every string field
but condition, which took true; the string "1" for schema_version; the
string "two" for nodes. A heading carrying a list of one text node is
schema-valid, and it was admitted with no finding before and is now.
document.invalid_id and document.invalid_title are retired. Each was
raised for an id or a screen title that is there and is not a string
(lib/riddler/screens/document.ex, the private id_findings/1 and
title_findings/1, read at ef87a50ac596ab3ce5f2ed3138c035626470fc4e). No
document admit/1 takes carries such a value now, so neither code has a value
left to be raised for, and both checks are removed from validate/1. A string
id or title was never a finding, so no document that validated clean
before stops doing so. That holds of a struct admit/1 builds; a struct a
host builds itself can still carry an id or a title that is not a string,
and unlike a key or a condition of the wrong type, whose clauses stay (below),
such a struct raised document.invalid_id or document.invalid_title at
ef87a50ac596ab3ce5f2ed3138c035626470fc4e and now raises neither, the field
passing validate/1 unreported.
The other codes the wrong-typed values raised keep the values that still
reach them. document.unknown_kind, document.invalid_schema_version,
document.invalid_key, document.unknown_type and
document.invalid_condition are each raised by a value the schema admits: a
kind no runtime knows, a version other than 1, a key that is not lower snake
case or is absent, a type the registry does not know or an absent one, and a
condition that does not parse. Each is stated by a case in
corpus/screens/admit.json. The clauses of validate/1 that answer a key or
a condition that is not a string stay (the private key_findings/2 and
condition_findings/2 in lib/riddler/screens/document.ex), and so does the
matching clause of response validation (the private cause/2 in
lib/riddler/screens/validation.ex). admit/1 never builds a struct that
reaches them now; a struct a host builds itself can, and those clauses are
what keep validate/1 and response validation answering such a struct rather
than raising.
The corpus states the refusals. corpus/screens/admit.json carries one
case per typed scalar, a case for a kind written as null, a case for a
nodes that is not a list on a heading, and a case admitting a heading
carrying a list of nodes with no finding. The agreement check between the
schema and the admission corpus in test/riddler/corpus_test.exs, the test
named "admits exactly the values the admission corpus calls documents", now
compares both directions: it binds the schema's answer with a generator, where
it had bound it with a bare match that a comprehension also reads as a filter
and that dropped every value the schema refuses before the comparison ran.
Why an amendment and not a note
docs/adr/README.md states the test these records use: an entry is an
Amendment where "the rule stated above answers a call one way, and this entry
answers the same call another", and a new refusal is one of the two marks it
names as "each sufficient for an Amendment without being necessary". This
entry carries both. It refuses documents 0.1.0 and 0.2.0 admit. And it answers
three calls the record had answered the other way:
- The note above of 2026-09-17 for bead rd-xxb names
document.invalid_idfor "anidthat is not a string" anddocument.invalid_titlefor "a screentitlethat is not a string". Those values are no longer documents, and the two codes are retired. - The note above of 2026-09-18 for bead rd-6kl decides that an explicit
nullon the schema version, the document id and a screen's title stays admitted, under the words "neither arm moves". That arm moves: each of those threenulls is not a document. The node arm that note records - anullrequired,validatesorstyleraising its finding - does not move, because the schema does not type those fields and anullthere is schema-valid. - The note above of 2026-09-18 for bead rd-d9n says of a condition that is not valid predicator and one that is not a string that "The second and the third are each already a document finding". The third is now not a document. The response finding that note describes keeps all three of its causes, and the third is reached now only by a struct a host builds itself.
What is unchanged
priv/schemas/screen-document.schema.json is not edited. An absent field is
admitted, as the note above for bead rd-5v2 decides. The admitted struct has
the same shape: admit/1 tells an absent field from a null one by reading
the decoded map, not by recording absence on the struct, so the change the
note above for bead rd-6kl names as the cost of refusing a null is not
needed. Every document the schema admits answers what it answered before,
finding for finding, and no message changes.
Two things this entry does not reach. A metadata written as null is
still admitted, with no finding, while the schema refuses it
(lib/riddler/screens/document.ex, the private admit_metadata/1); it is not
one of the typed scalars this entry decides, and it is left where it stands.
And a schema_version of 1.0 is admitted, as the schema's integer, and then
raises document.invalid_schema_version from validate/1, which compares the
version with the integer 1 (the private schema_version_findings/1 in
lib/riddler/screens/document.ex); whether that number is version 1 is not
decided here either.
Noted 2026-09-18, campaign RF058, beads rd-lgi and rd-5uc. Two notes by
addition, each read against main at 6a3ee40. The first records what the
per-button opt-out does with a button key that is not a string, and why that is
left where it stands; the second says where the pattern compiler two passages
above name now lives, and where three line cites in the keyless-button
amendment above land today. Nothing above is changed, and neither note changes
what this record decides.
A button key that is not a string is the document's defect, and the opt-out
is deliberately not hardened against it (rd-lgi). The Decision above says
that every node has a key matching [a-z][a-z0-9_]*, and the amendment above
headed "a keyless button cannot opt out, and a call naming no button never
does" makes validates as false "meaningful only on a button that carries a
key, and only to a press that names that key". Neither says what a key that is
there and is not a string does to a press, and the package answers it this way.
Riddler.Screens.Document.admit/1 answers nil for a document whose button
carries 7, 0, false, true or null as its key (the private typed?/2
in lib/riddler/screens/document.ex), so such a key reaches response
validation only on a struct a host builds itself. On such a struct,
Riddler.Screens.Document.validate/1 reports the key as
document.invalid_key with a node_key of nil (the private key_findings/2
in the same file, its clause for a key that is there and is not a string).
Response validation compares the pressed key with the node's key as they are
(the private button?/2 in lib/riddler/screens/validation.ex), so on a
button keyed 7 that declares validates as false, a press of 7 answers
:ok, and a press of "7" names no button and validates in full. The test
named "a press equal to a non-string button key reaches that button's
opt-out", in the describe block "a node key the record does not admit" of
test/riddler/screens/validation_test.exs, pins those two answers and the
document finding; it is added by this entry's own request and so is citable at
no earlier SHA.
That is left where it stands. The defect is the document's, and the layer this
record names for it already reports it; the schema refuses such a key, and so
does admission. What the amendment above removed was an :ok reached with no
host naming anything, two absences comparing equal. A press equal to a
non-string key is a host naming, in the same term it wrote onto the struct, the
button it built, so the opt-out is still reached only through a button a press
names, which is the condition that amendment sets. Hardening button?/2 to
match only a string key would add a second rule about keys to response
validation, for a struct admit/1 never builds.
Why a note and not an amendment. The test these records state is whether an
entry changes an answer the record gave: an entry is an amendment where "the
rule stated above answers a call one way, and this entry answers the same call
another". No rule above answers a press equal to a key that is not a string,
and this entry records the answer the package already gives: no document that
validates clean stops doing so, no code, field, message or node key moves, no
refusal is added, and no code in lib/ changes with it: the request that lands
it edits two comments in lib/riddler/screens/compilers.ex and nothing else
under lib/. Deciding a question the record left open is not the test either: "Deciding an open question and
changing what the record decides come apart, and it is the second that
governs."
The pattern compiler two passages above place in Riddler.Screens.Validation
is compile_pattern/1 in Riddler.Screens.Compilers, and three line cites in
the keyless-button amendment above land on other lines today (rd-5uc). The
amendment above headed "an uncompilable pattern is the document's defect" says
of the function the document check compiles through "That function is in
Riddler.Screens.Validation", and the note above opening "Noted 2026-09-18.
The three amendments above move from proposed to accepted" calls it "the
one private regular-expression compiler in Riddler.Screens.Validation". Each
was true of the module on its date. The function is now compile_pattern/1 in
Riddler.Screens.Compilers (lib/riddler/screens/compilers.ex), a def
marked @doc false in a module marked @moduledoc false, and both checks call
it there: the response check from the private pattern/2 in
lib/riddler/screens/validation.ex, and the document check from the private
usable?/1 in lib/riddler/screens/type/text_question.ex. "Private" in the
second passage reads as no part of the package's surface: the function was a
def marked @doc false on that note's date as well (read at aa3e89d). What
each passage says of the function is as true in its new module: the document
check and the format compile through the one function, and it answers :error
rather than raising and never reaches the template compiler. The note of
2026-09-18 on the move in docs/adr/0001-riddler-one-package.md says the same
of that record's passages and of these two; this is ADR-0002's own entry.
The amendment above headed "a keyless button cannot opt out, and a call naming
no button never does" cites three places in lib/riddler/screens/validation.ex
by line, each read at 543f35275f05f735a34e88777201fa2e4beb1016: :101 for
the private button?/2, :94 through :99 for the private opted_out?/2, and
:39 through :41 for where the opt-out stands in front of the checks. Each
was right at that SHA, and none lands on its function now. Read at
6a3ee40a5f914a4208c0ff756fbee43249edccbd, button?/2 is line 172.
opted_out?/2 is lines 160 through 167, in two clauses: the first, at line
160, answers false for a pressed key of nil and was added by that
amendment's code half, and the second, at lines 162 through 167, is the search
the amendment describes. The opt-out stands in front of the checks at lines 48
through 50, the head of validate/4 and the if whose true branch answers
:ok. The function names are the anchors those cites resolve by; the lines
here are where they resolve at this reading.
Why a note and not an amendment. Naming the module a function now lives in and saying where three cites land changes no answer this record gave: no finding code, field, position or refusal moves with the file or the lines, and each passage says of the function what it said before.
Noted 2026-09-18, campaign RF058, beads rd-32y and rd-c2x. Notes by addition,
each read against main at 07b86e6759baad64b995004b971f86d4346f4672 unless
it names another SHA. Nothing above is changed; each paragraph below says what
the text above means now. In order, they are about the amendment above headed
"a keyless button cannot opt out, and a call naming no button never does",
about wording in earlier notes of 2026-09-18, and about the note headed
"response.undecidable keeps its response. prefix by decision, and this one
is the recorded exception rather than the pattern."
The keyless-button amendment is an amendment on the mark of a qualification,
not on what it takes away (rd-32y). Its section "Why an amendment and not a
note" quotes the counterweight this record states, that a decided reading "can
remain a note because its decided reading takes nothing away", and stops
there. The sentence it quotes, in the opt-out amendment's own "Why an
amendment and not a note", goes on: "and so changes nothing this record had
decided. Deciding an open question and changing what the record decides come
apart, and it is the second that governs." What "takes nothing away" is
measured against is therefore what the record had decided. The keyless-button
amendment measures it against the package instead, naming as what it takes
away "an :ok the package answers today". Measured against the record, the
case it closes had been left open: the opt-out amendment says of it "this
amendment decides nothing about it" and "Whether a keyless button should be
able to opt out at all is left open exactly as this amendment found it", and
the keyless-button amendment calls the paragraph there that describes a call
naming no button as "treated as a press through it" "a true description of
what the package did when it was written".
So the counterweight does not settle the classification by itself. What
settles it is the mark the amendment gives in its next paragraph, "a
qualification of the rule that amendment states" - the rule that validates
is read from the button the pressed key names - and docs/adr/README.md names
qualifying a rule an Amendment above states as one of two marks "each
sufficient for an Amendment without being necessary". On that mark it is an
amendment, and its Status: line is not touched here.
The hidden-button edge stays outside that amendment because the button is
not on the resolved screen, key or no key (rd-32y). The amendment's last
paragraph gives its reason for not reaching the edge as "that button carries a
key, and the question there is about resolution rather than about naming."
The first clause asserts what the amendment does not establish, and it is not
what places the edge outside it. A condition the root could not decide answers
false exactly as a condition the root decides false does (the private
evaluate/4 in lib/riddler/screens.ex), and a node whose condition answers
false is not shown. A node directly on a screen is answered nil and left off
the resolved screen (the private shown/3 in the same file), and a variant
candidate is passed over for the candidate after it (the private winner/3 in
the same file); either way the resolved screen does not carry it. So such a
button declares nothing to validation whether or not it carries a key, and
nothing the amendment decides about naming reaches it. The second clause is
the reason, and it stands. The note headed "A button whose own condition the
root could not decide is not on the resolved screen, so a press naming its key
validates that screen in full." answers the edge on this ground.
Both examples in the amendment's paragraph on how narrow the case is are
examples, and each has a sibling (rd-32y). The paragraph opening "The case
is narrower than a document carrying such a button" says that opted_out?/2
"takes the first node the match finds, so a keyless button earlier on the
screen carrying the default hides a later one declaring false". A keyless
button earlier on the screen declaring validates as true hides the later
one just as surely: the match took the first keyless button whatever it
declared, and only a false on that first one opted out (the private
opted_out?/2 in lib/riddler/screens/validation.ex, read at
543f35275f05f735a34e88777201fa2e4beb1016). The same paragraph says that "a
button whose own condition the root decides false is dropped from the resolved
screen"; a button whose condition the root could not decide is dropped the
same way, as the paragraph before this one says. Neither example overstates,
and the paragraph states the case itself in its last sentence, with the
examples as instances of it rather than a list. The first example and its
sibling describe the package before that amendment's code half: at this
reading opted_out?/2 answers false for a pressed key of nil in its first
clause, so no keyless button is matched and neither of those two hiding cases
arises. The second example and its sibling still hold at this reading, since
resolution still leaves such a button off the resolved screen; what is past is
only that the drop narrowed a keyless opt-out, there being none now to narrow.
In the entry directly below the keyless-button amendment, "none bears on the amendment above" means that none of its notes changes an answer that amendment gave (rd-32y). That entry closes its opening with "None of them changes what this record decides, and none bears on the amendment above." The amendment above it is the keyless-button amendment, and what the clause says of it is that none of that entry's notes changes an answer it gave. The phrase "bears on" is looser than that; the answer is the thing meant.
Of the three node fields the note on an explicit null names, required is
a text question's and style and validates are a button's (rd-32y). The
note headed "An explicit JSON null is admitted on the three envelope and
screen fields and refused on the three node fields, and both arms stay"
speaks of "the three node fields" without saying which node type carries
which. required is among the fields Riddler.Screens.Type.TextQuestion
names, and style and validates are among those
Riddler.Screens.Type.Button names (each module's fields/0). The finding
that note records for a null is raised on a node of the type that names the
field. On a node of any other type the field is not named, and admit/1
drops it: for a type the registry knows, admission takes only the fields that
type names (the private admit_typed/3 in lib/riddler/screens/document.ex),
and for a type it does not know, only type, key and condition (the
private admit_common/1 in the same file). A document admit/1 builds
carries nothing there, so nothing is raised for it.
Two phrases in the note deciding that none of the schema version, the document id and a screen's title is required (rd-32y). "Three surfaces say so, and none of them is changed" reads more exactly as "none of them moves": the third surface it names is this record, which gains that note. And "names no code", in "A recorded no takes nothing away, names no code and moves no finding", means that it names no finding code; the same paragraph names functions.
A rename of response.undecidable would tell no host about a defect it is
not told about today; what it would change is the code string (rd-c2x). The
note headed "response.undecidable keeps its response. prefix by decision,
and this one is the recorded exception rather than the pattern." says that a
rename "would buy a name and nothing else: no document would be admitted or
refused differently, and no host would read a finding it does not read
today." The last clause is loose: a rename changes the code string a host
matches on, which the same sentence says in its opening words, "A rename would
change a published finding code". The claim is about which findings are
raised, and it reads exactly as: no host would be told about a defect it is
not told about today. The same defects would be reported, on the same nodes
and fields; only the code string would change, and that is the cost the
sentence names.
The next finding of this kind is expected to take a prefix naming the call;
that sentence is an intention, not a rule (rd-c2x). The same note closes its
paragraph with "The next finding of this kind - one about the call rather than
about the visitor - takes a prefix naming the call when it is first written,
while naming it is free." Read as a rule, it decides how a code nobody has
written yet is to be spelled. docs/adr/README.md says what a note records -
"a note records where something already decided renders, or what a sentence
already accepted was about" - and the spelling of a code not yet written is
neither. The test it states for an amendment, whether the entry changes an
"answer the record gave", is not met either, because no answer above says how
such a code is spelled. So the sentence changes no answer, and as a rule it
would decide something a note does not record. It stands as what it can be
inside a note: the next finding of this kind is expected to take a prefix
naming the call when it is first written, while naming it is free. Nothing
here makes that binding; a rule for how finding codes are spelled would be
decided in an entry that carries a Status: line and can be accepted, not in
a note.
Why a note and not an amendment. docs/adr/README.md states the test
these records use: whether the entry changes an "answer the record gave", an
entry being an amendment where "the rule stated above answers a call one way,
and this entry answers the same call another". No note here answers a call
another way. Each says what a sentence above means, completes a quotation,
gives a reason, widens an example, or names which type carries a field, and
the last states as an intention what was phrased as a rule. No document that
validates clean stops doing so, no document that is refused stops being
refused, no code, field, message or node key moves, no refusal is added, no
Status: line changes, and no line of lib/ changes with this entry.
Noted 2026-09-19. The amendment above headed "a keyless button cannot opt out,
and a call naming no button never does" moves from proposed to accepted,
its Status line flipped in place and nothing else in it reworded. The
amendment headed "a value of the wrong type in a field the schema types is not
a document" stays proposed; this entry does not reach it.
It was verified against main at ade824f before the flip rather than
against the tree it was written on, because an amendment is accepted for what
the package does now. It said of itself that "the code half is a separate bead
and a separate commit; nothing here is citable from lib/ until it lands".
That commit is on main as e07daba, so the rule was read in lib/ rather
than in the behaviour the amendment described. Every cite was re-located by
anchor at ade824f:
- A call naming no pressed button never opts out: the private
opted_out?/2answers false for a pressed key ofnilbefore any node is looked at (lib/riddler/screens/validation.ex:160), andvalidate/4consults it in front of the checks (the same file,:49). - A keyless button node is never the pressed button: the private
button?/2is reached only with a key that is notnil(the same file,:172). Riddler.Screens.validate_screen/3passes a pressed key ofnil(lib/riddler/screens.ex:320), so it validates the resolved screen in full.- The
@docsentence the amendment named as becoming false has been edited by the code half as it said: the arity-3@docnow says a keyless button declaringvalidatesasfalse"is no exception" (lib/riddler/screens.ex:274). The@doconvalidate_screen/4and the README passage onvalidatesstill state the opt-out for the button a press names. - A button with no key is still
document.invalid_key, from the keyless clause of the privatekey_findings/2(lib/riddler/screens/document.ex:526). - The rule is pinned by "the arity-3 form presses no button and validates",
"does not opt out a press of nil handed to the arity-4 form" and "does not
opt the arity-3 call out when a later keyless button declares the opt-out"
(
test/riddler/screens/validation_test.exs:398,:452and:476).
The corpus case the amendment left "for the corpus pass" has since been taken
up and states exactly the rule it decides: "A screen carrying a button with no
key declaring that it does not validate is checked in full when the call
presses no button, because nothing can press a button nothing can name"
(corpus/screens/validate_screen.json:1328), expecting the screen's
response.required finding. Its sentence that every button in the case file
carries a key is a claim about the file at the SHA it labels, and holds there.
The edge it left outside itself, a press through a button hidden by its own
undecidable condition, is answered by the note above headed "A button whose
own condition the root could not decide is not on the resolved screen, so a
press naming its key validates that screen in full.", on the ground a later
note above gives for keeping it outside the amendment; this entry does not
reach it. The later notes above that read the amendment's wording, and the
readings they give, stand under the acceptance. CI on main at ade824f is
green.