Error recourse
What a template does when a tag fails: onerror=, default=, where the strategy comes from, and the one failure that is never recoverable.
A prompt is usually assembled at the worst possible moment — while a request is waiting. So the interesting question is not whether a construct can fail, but what the renderer emits when one does.
Recourse is an execution-time mechanism. onerror= chooses what appears in a failing
construct’s place instead of aborting the render. Lexer and parser errors sit outside it entirely: a
construct that does not parse never reaches execution, so there is nothing to substitute. That
boundary is the first thing to internalise — recourse cannot rescue a typo in the syntax, only a
failure in the work.
The vocabulary since v0.24.0
onerror= | Emits | Notes |
|---|---|---|
throw | nothing — the error propagates and the render fails | The default, and the fallback for any unrecognized value. |
default | the construct’s default= attribute | Empty when no default= is present, i.e. remove with an escape hatch. |
remove | empty string | |
keepraw | the construct’s original source, verbatim | Empty when no source was captured — it fails closed rather than emit a wrong slice of the document. |
log | empty string | Plus one WARN line naming the tag and the error. |
{~exons.var name="tone" onerror="default" default="neutral" /~}Where the strategy comes from
A document rarely states its own recourse for every tag. It does not have to — resolution walks three steps, and stops at the first that answers:
- The failing construct’s own
onerror=(for a branch, the branch’s own — see below). - The context default: the strategy the execution context reports, seeded from the engine’s
WithErrorStrategyand inherited by a loop body’s child context. throw.
So the host decides the posture and the document overrides it locally. A renderer built for drafts can be lenient everywhere without a single document opting in; a validator can be strict over the same documents.
A misspelling does not fall through
throw.
So onerror="remov" hard-fails under a renderer configured never to hard-fail — and the misspelling
is the only evidence. Engine.Validate reports it as an error on every shape that honours the
attribute, which is why that check is worth running before you ship a template.Which failures are governed since v0.24.0
| Construct | Failure |
|---|---|
| any tag | no resolver registered for the tag name |
| any tag | the resolver’s Validate refuses the attributes |
| any tag | the resolver’s Resolve returns an error |
exons.if / exons.elseif | the branch condition fails to evaluate |
exons.for | the in= path is not found in the context |
exons.for | the value found is not iterable |
exons.for | the host context cannot create a child context |
exons.switch | the dispatch eval= expression fails to evaluate |
exons.case | the case’s eval= expression fails to evaluate |
Everything else is a hard failure. Parse-time refusals — a missing eval=, item= or in=, a
non-numeric or negative limit=, an unclosed or mismatched construct — happen before execution. And
an error raised inside a selected branch or loop body belongs to that node’s own site: the
enclosing construct’s onerror= does not catch it.
The version marker on this section is load-bearing. Before v0.24.0 exons.if, exons.for and
exons.switch read the keys they needed and let the rest of the attribute map fall out of scope, so
onerror= and default= on a block construct were silently inert and every one of the failures
above was an unconditional render error. Writing the attribute was not wrong; it simply did nothing.
Branch-level recourse since v0.24.0
An exons.elseif and an exons.case each carry their own onerror=, honoured at the branch’s own
position rather than the opening tag’s:
{~exons.if eval="input.verbose"~}
long form
{~exons.elseif eval="input.terse" onerror="remove"~}
short form
{~/exons.if~}This matters because a condition is the most common thing to get wrong, and a broken condition in the third branch of a chain should not be reported against the first.
default= has two readings, and they never collide
On exons.var and exons.input the attribute name also carries a resolver-level meaning — a lookup
miss, or an empty declared value. That path returns a value rather than an error, so it resolves
before recourse is ever consulted. default= as recourse is read only by onerror="default".
Inheritance is never recoverable since v0.25.0
{~exons.extends~} carries no onerror=, and no strategy — not even remove — makes it survivable.
Inheritance is resolved before any tag executes, because the parent chain decides which document is
executed at all. There is no node whose failure a strategy could absorb. So an unresolvable
extends fails the render outright, under every configuration.
That is deliberate rather than unfinished. Degrading it would render the child’s bare block bodies — a different document than the author wrote, handed back as a success. A caller has no way to detect that, which makes it strictly worse than an error.
Telling the two apart in a registry
cuserr and its tag metadata is extends. That holds for all three ways resolution can
fail — the declaration cannot be read, there is no engine to resolve through, or the chain cannot be
walked (missing parent, circular, too deep). Do not branch on the error’s code: it is derived from
the underlying message’s wording, not set by the library.