Skip to main content
Version: 2.5

Trigger Context and Change Origin

Availability

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:

PropertyMeaning
$trigger.originWho performed the immediate change that triggered this run.
$trigger.initiatorWho 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 runorigininitiator
Effect 1 listening to Auseruser
Effect 2 listening to Beffectuser
Effect 3 listening to Ceffectuser

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

PropertyDescription
typeHow the current effect was started.
originImmediate origin of the current run. mixed represents relevant changes with different origins.
initiatorRoot initiator of the causal chain.
changesConcrete changed paths relevant to the current effect's listen. Present for value-change runs.
chainIdOptional correlation identifier for diagnostics.
parentEffectIdOptional ID of the effect whose action directly caused this run.

Origin values

Changeorigin
The user enters a value through a widget/CVAuser
A set, copy, or clear action writes a valueeffect
An effect applies clear with mode: "reset"effect
Initialization or a default writes a valuesystem
The form is reset outside an effectsystem
Host code or an API result patches the form programmaticallysystem
One run contains relevant changes with different originsmixed

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.customer and only the name changes, the concrete change path is $value.customer.name.
  • A $row effect receives changes for the concrete row represented by its current $row value.
  • 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:

  • $trigger at the effect-expression root;
  • type, origin, initiator, and changes after $trigger.;
  • valueChange, widgetEvent, invoked, and initialization when comparing type;
  • user, effect, system, and mixed when comparing origin;
  • user and system when comparing initiator.

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 $trigger run as before.
  • $trigger is transient expression metadata, not form data.
  • No bulk schema migration is required.
  • A schema that uses $trigger requires a runtime version that implements this contract.

Runtime requirements

The runtime implementation follows these rules:

  1. Record origin for the concrete value change; do not infer it only from the control's persistent dirty state.
  2. Mark effect action writes as effect before they trigger the next value-change pass.
  3. Mark direct CVA/widget changes as user.
  4. Mark initialization, defaults, external patches, and form resets outside an effect as system unless they carry more precise explicit metadata.
  5. Preserve initiator and chainId through a cascade, but assign a new immediate origin for every write.
  6. Build changes from the intersection of actual changed paths and the current effect's listen paths.
  7. Keep $trigger in the immutable execution frame used by diagnostics and asynchronous continuations.
  8. Keep cycle detection, maximum cascade depth, and global rate limiting unchanged.