Constraint Execution Model

FCS triggers execute constraints in a specific order with a specific scope model. Understanding this is essential for multi-step pipelines like "call API → download result → vectorize."


How constraints execute

A trigger is an FCS object with a Constraints array. Each element in the array is processed sequentially (one after another), and after each constraint runs, the FCS frame is re-evaluated so that subsequent constraints see updated values.

myTrigger := {
   Constraints = [
      constraintA,    # runs first, frame re-evaluated after
      constraintB,    # runs second, sees updated state from A
      constraintC,    # runs third, sees updated state from A + B
   ],
}

Key rule: The total number of constraints in the array must not change between sequential runs. If it does, you get:

"The number of sequentially running constraints in a trigger must not change between the individual runs."


Batch (flat) vs Sequential (nested) constraints

Batch: constraints at the same nesting level

When multiple constraint objects appear inside the same array element (via sub-array flattening), they execute as a batch — all evaluated against the same frame snapshot.

# These two constraints run in the SAME batch (same frame)
trigger := {
   Constraints = [
      [constraintA, constraintB],    # both see the original frame
   ],
}

Sequential: constraints at top-level array positions

When constraints occupy separate top-level positions in the Constraints array, they execute sequentially with frame re-evaluation between them.

# These run SEQUENTIALLY (frame refreshed between each)
trigger := {
   Constraints = [
      constraintA,    # position 0: runs first
      constraintB,    # position 1: runs second, sees A's changes
   ],
}

Combined: reference expansion

When you reference another trigger's Constraints (e.g., recognitionTrigger.Constraints), the referenced array is inlined. This is how chaining works:

# Each .Constraints reference becomes one element in the outer array
combinedTrigger := {
   Constraints = [
      recognitionTrigger.Constraints,       # element 0: all of recognition's constraints as one batch
      downloadMaskTrigger.Constraints,      # element 1: all of download's constraints as one batch
   ],
}

In CheckFixTheExpressionTrigger, the outer loop iterates constraints.Count times (the top-level array length). For each position i, EnumerateGetListValues(constraints[i]) recursively flattens any nested arrays or string references into individual constraint objects that execute within the same batch.

Source: MsContraints.cs:296-347 (sequential loop), MsContraints.cs:203-232 (recursive flattening)


VariableScope

Each constraint can specify where its Variable path is resolved relative to. This is controlled by the VariableScope attribute.

{
   Variable = "myVar",
   VariableScope = "global",      # resolve myVar from root
   Condition = False,
   InitByGet = { Url = someUrl, MediaFormat = "object" },
}

Scope values

Value Behavior When to use
(default) fromconstraint Resolved relative to the constraint's own path in the FCS tree Most cases — variable lives near the trigger
"global" Resolved from the FCS root Variable is at the top level of the component, trigger is nested
"local" Resolved relative to the constraint path with tail appended Variable is a child of the constraint's parent
"parent" or "parent:N" Goes up N levels from the constraint path, then appends variable name Variable is N levels up from the constraint location

Source: MsContraints.cs:351-453

Practical example

If a trigger at path subComponent.details needs to set a variable resultData that lives at the FCS root:

{
   Variable = "resultData",
   VariableScope = "global",
   Condition = False,
   InitByGet = { Url = apiUrl, MediaFormat = "object" },
}

Without VariableScope = "global", the engine would look for subComponent.details.resultData and fail.


The "multi-step constraints" error

When you chain triggers like this:

detectBuildingsTrigger := {
   Constraints = [
      recognitionTrigger.Constraints,    # step 1: call API
      downloadMaskTrigger.Constraints,   # step 2: download mask (depends on step 1)
   ],
}

Step 2 depends on step 1's output (the mask URL comes from the recognition response). The engine handles this correctly because:

  1. Step 1 executes → PVC updated with reconResponse
  2. Frame re-evaluated → maskAbsoluteUrl now resolves correctly
  3. Step 2 executes → uses the fresh URL

However, the trigger path must still be findable after the frame update. If the trigger is defined with a path that changes between steps, you get:

"Failed to evaluate constraint in trigger path '...' for the constraint [N of M] run. Did any previous constraint run modify this array of constraints? Please consider using global trigger for these multi step constraints."

Fix: Define the combined trigger at the FCS root level (or use VariableScope = "global") so the path remains stable across frame updates.


Constraint attributes reference

Each constraint object supports these attributes:

Attribute Type Purpose
Variable string Target PVC variable name
VariableScope string Variable resolution scope (see above)
Condition bool If False, the constraint fires (unconditional). If True, skipped
Validation bool If False, stops all triggers and shows explanation. Used for pre-checks
Explanation string User-visible message (shown as note or error)
InitByGet object { Url, MediaFormat, TimeoutMs, BasicAuthCredentials } — HTTP GET
InitByPost object { Url, Body, MediaFormat, TimeoutMs, BasicAuthCredentials } — HTTP POST
Init expression Direct value assignment (no REST call)
InitReset bool If True, resets the variable (removes from PVC)
InitCopyFromPvcPath string Copy PVC values from another path
InitCopyFromPvcSliceToken string Copy PVC from a remote component
InitTransform / ResponseTransform callable Transform function applied to the response
UpdateAssumptionConditions bool Re-evaluate assumption conditions after this constraint

MediaFormat values

For GET:

Value Result
"object" Parse JSON → FCS object
"base64" Binary → base64 string
"string" Raw text string

For POST:

Value Request → Response
"string2string" string body → string response
"object2string" JSON body → string response
"object2object" JSON body → JSON response
"string2translatedString" string body → localized string
"object2translatedString" JSON body → localized string
"object2translatedObject" JSON body → localized JSON

Source: MsContraints.cs:17-41 (attributes), ParameterValueConstraints.cs:186-211 (REST call dispatch)


Quick vs Non-Quick Trigger Execution

When a trigger fires (via a button click or the update-properties API), the system decides whether to run it inline (quick) or as a background WebJob (non-quick). This determines which container processes the constraints.

How it works

Mode Where constraints run When used
Quick (inline) Engine container (via webapp) Fast triggers, user edits with value changes
Non-quick (WebJob) WebJobs2 container Heavy/long-running triggers, explicit opt-out of quick

What controls the mode

The IsQuick() function in ComponentUpdateService.cs:40-50 evaluates these conditions — if any is true, the trigger runs as quick:

=> validationTrigger.IsQuick                                    // 1. :quick suffix in trigger name
    || string.IsNullOrEmpty(validationTrigger.Name)             // 2. no trigger name
    || validationTrigger.Name.EndsWith("__QuickTrigger")        // 3. naming convention
    || (request.EditedDiff != null && request.EditedDiff.Items.Count != 0)  // 4. user changed values

Additionally, isMoreThanAction (line 343) forces quick when the VM's ParamValueDto is not a ParamValueActionVM (i.e., the trigger is associated with a value change, not a button click).

FCS definition

Fcs.Parameter.ItemAction{
   HumanName = "🚀 Run Pipeline",
   ValidationTrigger = "myTrigger",
   IsQuick = True,          # True → UI appends :quick suffix
   IsAuthPost = False,
   CssStyleClass = "fcs-parameterItem-inMenuButton"
}

When IsQuick = True, the Aurelia frontend appends :quick to the trigger name before sending. When IsQuick = False, no suffix is appended.

The :quick suffix protocol

The UI constructs the trigger name with suffixes that the server parses:

detectBuildingsTrigger:quick       → IsQuick = true
detectBuildingsTrigger:authpost    → IsAuthPost = true (authenticated POST)
detectBuildingsTrigger             → parsed as-is (may still be quick via other conditions)

Source: ParamValueVMToolsWeb.cs:72, ComponentUpdateService.cs:338-370

Calling the API for non-quick mode

To force the non-quick (WebJob) path via the update-properties API:

  1. Load properties for any variable to get the base VM
  2. Set ParamValueDto = null to avoid isMoreThanAction = true
  3. Send the trigger name without :quick
  4. Ensure no edited values in the VM (same values as original PVC)
# Step 1: Load VM
$vm = (POST /load-properties {contextPath: "someVar"}).Content | ConvertFrom-Json

# Step 2: Null out ParamValueDto
$vm.ParamValueDto = $null

# Step 3: Send without :quick
POST /update-properties {vm: $vm, validationTrigger: "detectBuildingsTrigger"}
# Response: {"ResultType": "ResultJobStatus", "jobstatus": {JobId: "abc...", CommitUrl: "..."}}

The response returns a ResultJobStatus with:

# Step 4: Poll job
GET /api/spaces/{space}/jobs/{jobId}/status  → "done"

# Step 5: Commit result
POST /{space}/en/Properties/CommitUpdateResult?resultKey={key}
# Body: {ComponentId, ComponentSubRevision, ComponentPrivateKey, OriginalPvcB64}

When to use non-quick

Important limitation

Non-quick mode only affects constraint execution (the InitByGet/InitByPost HTTP calls). Lazy FCS expressions (like maskImageData := Fcs.Presentation.ImageData.FromBase64(...)) are always evaluated by the engine on demand, regardless of quick/non-quick. If the engine container lacks native dependencies, those expressions will still fail.

To fix engine-side SkiaSharp: Add native deps to Dockerfile-engine (see Dockerfile-jobs for reference).

Source: ComponentUpdateService.cs:40-50 (IsQuick), ComponentUpdateService.cs:152-194 (dispatch), ComponentUpdateService.cs:229-244 (commit after job)


String constraint indirection

Constraint arrays support string references — instead of embedding constraint objects directly, you reference them by name. The engine resolves strings at execution time via CascadeEvaluator (MsContraints.cs:208).

Why it matters

When you embed trigger objects directly:

# PROBLEM: maskSmoothGeom is evaluated when this object is CONSTRUCTED
# (before steps 1-2 populate maskBase64)
detectBuildingsTrigger := {
   Constraints = [
      recognitionTrigger.Constraints,    # triggers eager evaluation of everything
      vectorizeTrigger.Constraints,      # Init = maskSmoothGeom evaluated NOW → {}
   ],
}

With string references, resolution is deferred to execution time:

# CORRECT: each string resolved only when constraint engine processes it
detectBuildingsTrigger := {
   Constraints = [
      "recognitionConstraint",
      "downloadMaskConstraint",
      "vectorizeConstraint",
   ],
}

Important: strings must resolve to constraint objects

The string must resolve to a constraint object (with Variable, Condition, Init, etc.), NOT a trigger wrapper ({Constraints: [...]}). If it resolves to a trigger object, CheckFixConstraint won't find the Variable attribute and silently skips it.

# WRONG: "myTrigger" resolves to {Constraints: [...]}, not a constraint object
detectBuildingsTrigger := { Constraints = ["myTrigger"] }

# CORRECT: "myConstraint" resolves to {Variable: "x", Condition: False, Init: ...}
detectBuildingsTrigger := { Constraints = ["myConstraint"] }

How it works in the engine

In EnumerateGetListValues (MsContraints.cs:203-230):

case string constraintExpression:
    var (foundPath, foundConstraint) = CascadeEvaluator.TryDeleteScopeSafeCascadeRead(
        frame, localPath, constraintExpression);
    foreach (var subItem in EnumerateGetListValues(foundConstraint, ...))
        yield return subItem;

The top-level Constraints attribute also supports string indirection (MsContraints.cs:150):

# The Constraints attribute itself can be a string
myTrigger := { Constraints = "sharedConstraintArray" }

Source: MsContraints.cs:150-151 (top-level), MsContraints.cs:208-218 (recursive)


Lazy vs Eager in constraints (:= vs =)

FCS uses := for lazy evaluation and = for eager evaluation. This distinction is critical inside constraint definitions.

The rule

Always use := (lazy) for Init in multi-constraint triggers. Eager = evaluates the expression when the constraint object is constructed, which may be before preceding constraints have populated their target variables.

# WRONG: Init evaluated when constraint object is built (before mask is downloaded)
vectorizeConstraint := {
   Variable = "buildingOutlines",
   Init = maskSmoothGeom,        # eager: evaluated NOW, before maskBase64 exists → {}
}

# CORRECT: Init evaluated when constraint engine processes this constraint
vectorizeConstraint := {
   Variable = "buildingOutlines",
   Init := maskSmoothGeom,       # lazy: evaluated at processing time, after mask is downloaded
}

How Init := works with CapturedLazyValueExpression

At MsContraints.cs:557:

triggerReponse.rhs = new CapturedLazyValueExpression(
    () => constraint.GetAttributeValue(InitAttribute), Frame);

The lambda captures the constraint and frame. When Init is lazy (:=), GetAttributeValue triggers lazy evaluation at the moment the rhs is consumed — not at capture time. Combined with string indirection, this provides double safety for multi-step pipelines.

Source: MsContraints.cs:555-558, ParameterValueConstraints.cs:154-157


Geometry persistence in PVC

FCS geometry objects (FcsBufferedGeometry) are not PVC-serializable. Attempting to store one via Init into an ItemGallery gives:

"Can not convert 'FCS.Geometry.Buffering.FcsBufferedGeometry' to ParameterValueClass"

The solution: persistence strings

Serialize geometry to a string with ToPersistenceString() and parse back with Fcs.Geometry.Buffered.Parse():

# ── Store in PVC as string (runs in trigger / WebJobs) ──
buildingOutlines := ""    # ItemString, not ItemGallery

vectorizeConstraint := {
   Variable = "buildingOutlines",
   Condition = False,
   Init := maskSmoothGeom.ToPersistenceString(-8),   # serialize to compact string
}

# ── Read from PVC in scene generation (runs in Engine, no SkiaSharp needed) ──
hasBuildingOutlines := buildingOutlines != ""
gblock {gb} gcomposite (hasBuildingOutlines
   ? [Fcs.Geometry.Composite(Fcs.Geometry.Buffered.Parse(buildingOutlines))]
   : []) if (hasBuildingOutlines)

Precision parameter

ToPersistenceString(precision) controls output compactness:

Precision Meaning Example
16 (default) 16 significant digits Full precision, large strings
-8 8 significant digits Good balance for mask-derived geometry
-12 12 significant digits Used in diagnostics

Negative values = significant digits (compact). Positive = decimal places.

The persistence string format

(x1,y1 x2,y2 ...)[v1,v2 v3,v4 ...][+0+1+2 -0+3+4][+0 +1]
 ↑ vertices       ↑ edges (curves)  ↑ loops (oriented) ↑ areas/solids

Example with 3 building polygons:

(46,-26.6 49.2,-27 47,-21.2 ...)[0,1 1,2 2,3 3,0 ...][+0+1+2+3 +4+5+6+7+8 +9+10+11+12+13+14][+0 +1 +2]

Source: FcsBufferedGeometry.cs:78-79, BufferedGeometry3D.cs:32-33, GeometryDiag.cs:82


Best practices

  1. Keep combined triggers at root level to avoid path resolution issues after frame updates
  2. Use VariableScope = "global" when a nested trigger needs to write to a root-level variable
  3. All target variables must be exposed as InputItems — the constraint system can only write to variables declared in InputPageGallery_ParameterItemClasses (use VisibilityCondition = "False" to hide them)
  4. Use sequential (separate array positions) when step N depends on step N-1's output
  5. Use batch (nested arrays) when constraints are independent and can share the same frame
  6. Don't dynamically change the constraint count — the engine checks noConstraints != constraints.Count and throws if it changes
  7. Use Init := (lazy) always — eager Init = evaluates at object construction time, which may be before preceding constraints have run
  8. Use string constraint references for multi-step pipelines — strings are resolved at execution time, preventing eager evaluation leakage across trigger boundaries
  9. Store geometry as persistence strings — use ToPersistenceString(-8) to serialize, Fcs.Geometry.Buffered.Parse() to deserialize. Use ItemString (not ItemGallery) for the PVC parameter