Portal Community

Contents

  1. System Overview
  2. The Three-Layer Pipeline
  3. Step 1 — Parsing
  4. Step 2 — Directive Routing
  5. Step 3 — Option Application
  6. Template String Mode
  7. Context vs Request
  8. Caching
  9. Security Model

System Overview

Every field value in a workflow node can optionally contain one or more expressions. The framework detects these, resolves them at runtime using the correct directive, applies any requested transformations, and returns the final value to the node executor.

Core Insight If a field value contains no wildcard syntax, it passes through unchanged (zero overhead). Expression evaluation only triggers when a wildcard is detected.

The Three-Layer Pipeline

🔍
Parse
🧩
Directive
🔧
Options

Response
LayerResponsible ClassPurpose
OrchestrationExpressionOrchestratorEntry point — detects wildcards, coordinates parsing, routing, caching, option application
DirectiveBaseDirectiveEvaluator + implementationsResolves a single expression to a raw value using domain-specific logic
OptionsIDirectiveOption implementationsPost-resolution transforms (format, validate, encode, cache)

Step 1 — Parsing

The active IWildcardParser inspects the field string and answers three questions:

  1. CanParse? — Does the string contain any wildcard patterns?
  2. IsTemplate? — Does it contain multiple expressions or surrounding literal text?
  3. Parse / FindAllRegions — Extract directive tokens, path, and option tokens
// Input field value
"{@ $ctx.user.name @uppercase }"

// After parsing → WildcardParseResult
DirectiveToken[] Directives = [
    new("ctx"),       // first directive (routes to $ctx handler)
    // second directive would be data-source qualifier if present
]
string Path    = "user.name"
string[] Options = ["uppercase"]
string RawExpression = "{@ $ctx.user.name @uppercase }"

Three parsers are registered. The active one is selected via IWildcardParserResolver based on EvaluationContext.ActiveSyntax:

ParserSyntaxWhen to use
AtBraceWildcardParser{@ $dir.path @opt }Default for all BizFirst workflows
DoubleBraceWildcardParser{{ $dir.path @opt }}Importing n8n / Handlebars workflows
AtSignWildcardParser@alias.pathCanned expression short form

Step 2 — Directive Routing

The orchestrator looks up Directives[0].Name in the DirectiveServiceRegistry (DI-registered map of name → IExpressionDirectiveService). The matched directive's InvokeAsync is called, which:

  1. Starts a Stopwatch for duration tracking
  2. Calls the abstract EvaluateAsync method on the concrete directive class
  3. Attaches elapsed milliseconds to the EvaluationResponse
No $-prefix in DI keys The parser strips the $ character. The DI key for $ctx is "ctx", for $var is "var". This keeps registration clean.

If no directive matches, the IDefaultExpressionDirectiveService fallback is used (typically the $var directive, configurable per host).

Step 3 — Option Application

After the directive returns a raw value, registered options are applied in order:

  1. PopulateConstraints(FieldConstraints) — each option records Required/NotEmpty/DefaultValue constraints
  2. Apply(resolvedValue) — each option transforms the value (uppercase, JSON encode, etc.)
  3. FormattedValue — lazy string property on EvaluationResponse, computed on first access
Special: ICacheMarkerOption Options that implement ICacheMarkerOption (e.g., @cache, @cache-thread) are handled by the orchestrator before the directive is called. A cache hit short-circuits the entire directive invocation.

Template String Mode

When IWildcardParser.IsTemplate(text) returns true, the orchestrator enters interpolation mode:

// Template input
"Dear {@ $ctx.user.name }, your order {@ $var.orderId } is ready."

// Process:
1. FindAllRegions() → [{start:5, end:28}, {start:43, end:62}]
2. Evaluate all regions in parallel (Task.WhenAll)
3. Substitute each FormattedValue into original string
4. Scan assembled string for remaining wildcards
5. If new wildcards found AND Depth < 10 → re-enter pipeline
6. Return assembled string

The VisitedKeys set is shared across all passes, preventing infinite loops when a resolved value itself contains a previously-seen expression.

Context vs Request — Two Key Objects

ObjectLifetimeCreated byContains
EvaluationContext Per node execution BaseNodeExecutor TenantId, ExecutionId, NodeKey, IsolationLevel, ActiveSyntax, Cache, Memory, NodeExecutionData, CurrentUser
EvaluationRequest Per expression call ExpressionOrchestrator Parsed Directives[], Path, RawExpression, Options[], Depth (chain depth), VisitedKeys, reference to Context
Key Design Principle EvaluationContext is created once and reused for all field evaluations within a node execution. EvaluationRequest is immutable — chaining creates a new request via request.Chain() or request.ForSubDirective().

Caching

The IExpressionCache is scope-aware. Cache scope is controlled by the @cache option family:

OptionScopeInvalidated when
@cache (default)NodeNode execution completes
@cache-threadThreadWorkflow thread completes
@cache-processProcessProcess (top-level workflow) completes

Cache is purely in-memory — no Redis. Cache hits set EvaluationResponse.CacheHit = true and DurationMs = 0.

Security Model

The ExpressionIsolationLevel on the EvaluationContext controls which directives are permitted:

LevelPermitted directivesUse when
SafeAll data-read directives: $ctx, $var, $input, $output, $exec, $flow, $items, $mathDefault for all nodes
SandboxedAll Safe + $js (Jint sandbox)Nodes that need script logic
TrustedAll + $cs, $apiRequires EnableTrustedExecutionEnvironment=true
Hard Error on Violation Attempting to use a directive that exceeds the context's isolation level returns an EvaluationResponse.Failure with EvaluationErrorCode.IsolationViolation — not a silent null.