Trigger Context and Change Origin
This page specifies a proposed Form Effects contract. Do not use $trigger in
production schemas until the matching Fluent Forms runtime and Form Designer
version is deployed.
Every effect run has a cause. A user may edit a control directly, another effect may write the value, or the form may apply a value during initialization, reset, or an external programmatic update.
The transient $trigger expression variable makes that cause available to
when and the other expressions evaluated by the current effect run.
Use it when an effect must distinguish direct user input from an automatic update:
{
"id": "react-only-to-manual-text-edit",
"listen": ["$value.text"],
"when": "${$trigger.origin == 'user'}",
"do": [
{
"type": "set",
"field": "lastManualText",
"value": "${$value.text}"
}
]
}
The condition passes when the user edits text through its widget. It does
not pass when another effect, a default, a form reset, or host code changes
the value.
Direct origin and chain initiator
$trigger keeps two related concepts separate:
| Property | Meaning |
|---|---|
$trigger.origin | Who performed the immediate change that triggered this run. |
$trigger.initiator | Who started the complete causal chain. |
This distinction matters for chained effects:
user changes A
-> effect 1 sets B
-> effect 2 sets C
-> effect 3 reacts to C
| Effect run | origin | initiator |
|---|---|---|
| Effect 1 listening to A | user | user |
| Effect 2 listening to B | effect | user |
| Effect 3 listening to C | effect | user |
The user origin does not propagate as the immediate origin of writes made by effects. If it did, an automatically updated control would look as if the user had edited it manually.
Use origin for direct-input rules:
{
"when": "${$trigger.origin == 'user'}"
}
Use initiator only when every step in a chain started by the user should be
eligible:
{
"when": "${$trigger.initiator == 'user'}"
}
Trigger context reference
The logical context has this shape:
type EffectTriggerType =
| 'valueChange'
| 'widgetEvent'
| 'invoked'
| 'initialization';
type EffectChangeOrigin = 'user' | 'effect' | 'system' | 'mixed';
interface EffectTriggerChange {
path: string;
origin: 'user' | 'effect' | 'system';
}
interface EffectTriggerContext {
type: EffectTriggerType;
origin: EffectChangeOrigin;
initiator: 'user' | 'system';
changes?: EffectTriggerChange[];
chainId?: string;
parentEffectId?: string;
}
The object is an immutable snapshot of one effect run. It is not stored in the form value and must not leak into a later, unrelated interaction.
Properties
| Property | Description |
|---|---|
type | How the current effect was started. |
origin | Immediate origin of the current run. mixed represents relevant changes with different origins. |
initiator | Root initiator of the causal chain. |
changes | Concrete changed paths relevant to the current effect's listen. Present for value-change runs. |
chainId | Optional correlation identifier for diagnostics. |
parentEffectId | Optional ID of the effect whose action directly caused this run. |
Origin values
| Change | origin |
|---|---|
| The user enters a value through a widget/CVA | user |
A set, copy, or clear action writes a value | effect |
An effect applies clear with mode: "reset" | effect |
| Initialization or a default writes a value | system |
| The form is reset outside an effect | system |
| Host code or an API result patches the form programmatically | system |
| One run contains relevant changes with different origins | mixed |
system deliberately groups programmatic sources that are not effect writes.
A future version may add more detailed source metadata without changing the
meaning of origin.
Combining origin with business conditions
$trigger is part of the existing JEXL condition instead of a separate
listenOrigin schema property. This keeps trigger filtering composable:
{
"listen": ["$value.text"],
"when": "${$trigger.origin == 'user' && $value.enabled}",
"do": [
{
"type": "set",
"field": "confirmedText",
"value": "${$value.text}"
}
]
}
Effects that do not reference $trigger keep their existing behavior.
Multiple changes in one run
A value-change effect can listen to multiple paths. One settled runtime pass may contain more than one relevant change.
When all relevant changes have the same immediate origin, $trigger.origin
contains that origin. When their origins differ, it is mixed and the exact
information is available in $trigger.changes:
{
"type": "valueChange",
"origin": "mixed",
"initiator": "user",
"changes": [
{"path": "$value.firstName", "origin": "user"},
{"path": "$value.displayName", "origin": "effect"}
]
}
To accept a run when at least one relevant change came directly from the user, inspect the collection:
{
"when": "${$trigger.changes.some(change => change.origin == 'user')}"
}
changes contains only paths relevant to the current effect's listen, not
every changed value in the root form.
Parent paths and array rows
- When an effect listens to
$value.customerand only the name changes, the concrete change path is$value.customer.name. - A
$roweffect receives changes for the concrete row represented by its current$rowvalue. - A control that became user-dirty during an earlier interaction does not make a later programmatic sibling change a user change.
- Inner forms and Characteristics preserve the per-change metadata when they propagate their value to the owning form.
Initialization
An effect with runOnInitialization: true receives a fresh initialization
context:
{
"type": "initialization",
"origin": "system",
"initiator": "system",
"changes": []
}
Therefore an effect whose condition is only
${$trigger.origin == 'user'} does not run its actions during initialization.
To explicitly allow both cases:
{
"when": "${$trigger.type == 'initialization' || $trigger.origin == 'user'}"
}
Invoked effects
An effect started through runEffect has type: "invoked" and
origin: "effect". It inherits initiator from its caller:
{
"type": "invoked",
"origin": "effect",
"initiator": "user"
}
An invocation outside an existing user chain uses initiator: "system".
Widget events
A widget-event effect has type: "widgetEvent".
The runtime may use origin: "user" only when it can reliably identify the
semantic event as a direct user interaction. An event emitted as the result of
a programmatic operation is not automatically a user event merely because a
widget emitted it. Unclassified events use the conservative system origin.
The existing $event and $payload variables remain the source of the widget
event name, pointer, row, and payload. $trigger describes causality; it does
not replace them.
Availability in effect expressions
The same $trigger snapshot is available throughout one effect execution:
when;set.value;scriptData;runEffect.inputs;- the success and error branches of the same script action.
Asynchronous continuations preserve the snapshot of their originating run. They do not read the trigger context of a newer form change.
Expression editor assistance
Every expression editor used to author an effect when condition provides the
same context-aware suggestions. This includes the Form Logic Editor, the effect
detail editor, and any shared JEXL editor used for the same property.
Autocomplete provides:
$triggerat the effect-expression root;type,origin,initiator, andchangesafter$trigger.;valueChange,widgetEvent,invoked, andinitializationwhen comparingtype;user,effect,system, andmixedwhen comparingorigin;userandsystemwhen comparinginitiator.
The suggestions come from one shared effect-expression catalog so the editors,
schema validation, and runtime contract cannot drift independently. The
when editor remains JEXL-only; $trigger does not introduce a static-value
mode or another expression language.
Editor coverage verifies the root variable, member completion, and the enum
values for type, origin, and initiator, then saves and reopens the
condition to prove that the expression round-trips unchanged.
$trigger is not suggested in ordinary widget expressions because those
expressions do not have an effect execution frame.
Diagnostics
An effect frame in the Form Runtime Debugger can expose:
type
origin
initiator
changes
chainId
parentEffectId
These values distinguish an effect rejected by when from an effect started
by direct input, another effect, or a mixed batch. They also correlate multiple
effect runs that belong to the same causal chain.
initiator: "user" is not proof that the current value was typed manually. Use
origin for that question.
Backward compatibility
- Existing persisted effect objects do not change.
- Existing effects that do not reference
$triggerrun as before. $triggeris transient expression metadata, not form data.- No bulk schema migration is required.
- A schema that uses
$triggerrequires a runtime version that implements this contract.
Runtime requirements
The runtime implementation follows these rules:
- Record origin for the concrete value change; do not infer it only from the
control's persistent
dirtystate. - Mark effect action writes as
effectbefore they trigger the next value-change pass. - Mark direct CVA/widget changes as
user. - Mark initialization, defaults, external patches, and form resets outside an
effect as
systemunless they carry more precise explicit metadata. - Preserve
initiatorandchainIdthrough a cascade, but assign a new immediateoriginfor every write. - Build
changesfrom the intersection of actual changed paths and the current effect'slistenpaths. - Keep
$triggerin the immutable execution frame used by diagnostics and asynchronous continuations. - Keep cycle detection, maximum cascade depth, and global rate limiting unchanged.