Trigger Context and Change Origin
$trigger is supported by the Fluent Forms runtime and Form Designer in tSM UI
2.4 builds that include TSM-4228. Older deployments do not evaluate or suggest
this variable.
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.
It is runtime metadata about this execution, not the persisted trigger
property that selects a widget event or an invoked effect in the schema.
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?: readonly 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. It contains
causes and paths, not the old or new field values; read current values from
$value.
Properties
| Property | Values | Meaning |
|---|---|---|
$trigger.type | valueChange, widgetEvent, invoked, initialization | How this effect execution started. It does not describe who changed a value. |
$trigger.origin | user, effect, system, mixed | Immediate origin of the change relevant to this execution. Use this to require a direct user edit. |
$trigger.initiator | user, system | Origin that started the relevant causal chain. An effect write can have origin: 'effect' and still have initiator: 'user'. |
$trigger.changes | Array of {path, origin} | Concrete changed $value paths relevant to this effect's listen, with the origin of each path. It is empty for initialization, widget events, invoked effects, and value-change runs without relevant form-value paths. |
$trigger.chainId | Generated string | Correlates effect executions in one causal chain. A later independent interaction receives a new ID. |
$trigger.parentEffectId | Effect ID or absent | Identifies the effect that invoked this one or, when unambiguous, wrote the value that started this execution. Root executions have no parent. |
The type marks changes, chainId, and parentEffectId optional. The current
runtime creates a chainId for a root execution and passes it down the chain;
it supplies changes: [] when there are no relevant changed form-value paths.
Do not use chainId or parentEffectId as stable business identifiers. A
missing parentEffectId can also mean that several effects wrote values in the
same batch or that the writer has no ID.
What type says
type | How it starts | Typical context |
|---|---|---|
valueChange | A path in listen changes after the form settles. The path may be a form value or another supported context value. | origin depends on the relevant value changes; changes lists matching $value paths when available. |
initialization | A value-change effect with runOnInitialization: true runs when the initial form state has settled. | origin: 'system', initiator: 'system', changes: []. |
widgetEvent | A declared widget event matches the effect's trigger. | origin: 'system', initiator: 'system', changes: [] in the current runtime. Use $event or $payload for event details. |
invoked | Another effect calls this effect with runEffect. | origin: 'effect', initiator inherited from the caller, changes: []. Passed inputs are in $input. |
type: 'valueChange' does not imply that a person edited a control. A host
patch, reset, or previous effect can start the same type of execution. Check
origin or initiator for that distinction.
One chain, two trigger snapshots
Suppose the user edits source. The effect copy-source listens to it and
sets derived; use-derived then listens to derived. Their snapshots are:
{
"type": "valueChange",
"origin": "user",
"initiator": "user",
"changes": [{"path": "$value.source", "origin": "user"}],
"chainId": "8fbd4c96-0b54-4f2c-9d4b-1e7a4a2e0fc1"
}
{
"type": "valueChange",
"origin": "effect",
"initiator": "user",
"changes": [{"path": "$value.derived", "origin": "effect"}],
"chainId": "8fbd4c96-0b54-4f2c-9d4b-1e7a4a2e0fc1",
"parentEffectId": "copy-source"
}
The ID is illustrative. Both executions belong to the same chain, but only
the first one directly observes the user's edit. A separate form interaction
gets a different chainId.
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.
In the current runtime, a widget event also has origin: 'system' even if the
user clicked the widget; the event alone does not prove that the user changed
a form value. mixed applies to a value-change run with relevant paths from
more than one origin. A future version may add more detailed source metadata
without changing the meaning of origin.
initiator answers a different question: what started this chain of relevant
changes? It remains user through later effect writes caused by a user edit.
For an independent programmatic patch it is system. If one settled pass
contains both user and system changes, each effect receives the initiator for
the paths it listens to; an effect listening only to the system path is not
classified as user-initiated.
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. Each item has two fields:
| Field | Meaning |
|---|---|
path | Concrete root-form path such as $value.customer.name or $value.orders[2].lines[1].source, with actual array indexes rather than a $row placeholder. |
origin | Immediate origin of that individual path: user, effect, or system. An individual change is never mixed; only the aggregate $trigger.origin can be mixed. |
changes is a list of causes, not a value diff: it contains no old or new
values. A value-change effect that listens only to $context can run with
changes: [], because there is no matching form-value path to report. For
widgetEvent, invoked, and initialization, the runtime also supplies an
empty array. Guard access to particular array items when it may be empty.
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 scoped nested-array effect receives changes for its concrete inner row;
$rowis that row, and$parentsidentifies the enclosing rows nearest first. - 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", origin: "system",
initiator: "system", and changes: [] in the current runtime. This remains
true when a person clicks the widget: the event identifies an action, but the
runtime does not classify that event itself as a direct form-value edit.
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.