Status: accepted (2026-08-13)
This records a decision already made and already shipped (the st-af3.7 branch); it is written after the fact to move the argument from a code comment into a standing record, and it changes no behavior. If review of this record concludes the behavior is wrong, that is a new issue against the behavior, not an edit here.
Context
<donedata> supplies the data field of the done.state.* event a
<final> state raises (and of the terminal done outcome at top level).
One of its forms is <content expr="...">, whose expression is evaluated
at done time. The question is what the done event's data carries when
that evaluation fails.
The spec answers it twice, and the answers conflict. Section 5.6, on
<content> generally:
When the SCXML Processor evaluates the
<content>element, if the 'expr' value expression is present, the Processor MUST evaluate it and use the result as the output of the<content>element. If the evaluation of 'expr' produces an error, the Processor MUST place error.execution in the internal event queue and use the empty string as the value of the<content>element.
Read literally, a failed <content expr> under <donedata> would make
_event.data the empty string. But section 5.10.1, on the event
structure's data field specifically:
The receiving SCXML Processor SHOULD reformat this data to match its data model, but MUST NOT otherwise modify it. If the conversion is not possible, the Processor MUST leave the field blank and MUST place an error 'error.execution' in the internal event queue.
"Leave the field blank" and "use the empty string as the value" name
different outcomes for the same field: in this engine's value space a
blank data field is nil (predicator's undefined), which is
distinguishable from "" by the exact comparison the conformance suite
uses. The routing clause B.2.6 sides with the field-specific reading:
When
<content>is a child of<donedata>, the Processor MUST interpret its value as defined in B.2.8.1 _event.data.
and B.2.8.1's ladder has no empty-string-on-error rung.
The tie is broken by a mandatory W3C conformance test.
test/scxml_tests/mandatory/content/test528_test.exs is exactly this
document - <content expr="return"/> under <donedata>, where return
is unbound - and its pass path is guarded by
cond="_event.data === undefined". That spelling is the W3C
conf:emptyEventData macro, recorded as a deliberate emission choice in
tools/corpus/scxml_w3/exclusions.exs, not a rewrite artifact. It passes
when data is nil and fails forever if data is "". The test's own
description string quotes 5.6's empty-string sentence, so the working
group published, as mandatory, a test whose assertion contradicts the
letter of the clause it cites - which is the strongest available evidence
of the intended semantics for the <donedata> context.
ADR-0002 makes deviations from the spec semantic bugs unless a comment
cites a mechanical reason. This deviation is semantic, not mechanical -
the engine could return "" and chooses not to - so it needs a decision
record, not just the comment at the fold site where it first landed.
Decision
When the evaluation of <content expr> under <donedata> fails, the
interpreter raises error.execution and the done event carries no data:
_event.data is nil (predicator undefined), not the empty string
5.6 names.
The site is donedata/2 in lib/statifier/interpreter/exit_entry.ex,
whose {:compiled, ...} arm raises via MachineState.raise_platform/4
and returns nil donedata on failure. Three reasons, in order of weight:
- The mandatory test asserts it. test528's
cond="_event.data === undefined"only passes withnil. Following 5.6's letter reddens a mandatory conformance test permanently. - The field-specific clause beats the generic one. 5.10.1 is
normative about the event's
datafield; 5.6 is normative about<content>'s output generically, and<content>'s primary context in 5.6 is a payload handed to an external service, where an output value is required and""is the safe one. 5.6 itself says "the use of the<content>element depends on the context in which it occurs", and B.2.6 routes the<donedata>context to B.2.8.1's_event.datarules, which have no empty-string rung. nilis the shape of "no data" everywhere else in the engine. A<final>with no<donedata>at all, and a<donedata>whose only<param>fails, both producenildata.""would make the error path the one place "nothing" is spelled differently.
Scope limit. This decision reaches <content expr> under
<donedata> only. It says nothing about <content> under <send> or
<invoke>, which are not yet implemented: when they land they must
answer the 5.6 question for their own context rather than inherit this
answer, and the default expectation there is the opposite - 5.6's
empty-string rule applies unchanged to a payload bound for an external
receiver, because B.2.6 routes those contexts to the Event I/O Processor
and the platform, not to B.2.8.1.
<send> has since answered this for its own context: ADR-0036 finds that a
failed <content expr> under <send> also yields no data, but by 6.2.2's
element-level discard rather than by this record's _event.data-blank-field
reasoning - the message is discarded before 5.6's empty-string rung would
apply at all.
Consequences
- test528 passes, and stays passing under the regression ratchet.
- The engine knowingly violates the letter of one MUST in 5.6, in one scoped context, with the contradicting MUST in 5.10.1 and the mandatory test as the recorded justification. A conformance reader diffing this interpreter against 5.6 finds the answer here instead of re-deriving it from a code comment.
- The fold-site comment in
lib/statifier/interpreter/exit_entry.excites this record instead of re-arguing the case inline, per ADR-0018's rule that ADR numbers are the durable citation form. - Whoever implements
<content>under<send>or<invoke>must decide the failure value for that context explicitly. If they conclude those contexts should also yield no data, that is an amendment to this record's scope limit, not a silent extension of it.