Saltar a contenido

Feel Expressions Strategy

BPMN usa FEEL para conditions: =amount > 100 and customer.tier == "premium". Camunda usa feel-scala (huge, ~50K LOC). MVP options: (1) build subset propio, (2) integrate feel-scala como library, (3) use JSONata (JSON-native), (4) CEL (Google's Common Expression Language). Recomendación: CEL — production-ready, multiple language SDKs (Go, Java, TS, Python), Postgres extension available, more pragmatic que FEEL para code-first teams. Pero NO is 100% BPMN-compliant. Trade-off documentado.

El problema

BPMN spec define FEEL (Friendly Enough Expression Language) para: - Gateway conditions: =amount > 100 - Input mappings: =customer.email - Output mappings: =response.body.orderId - Timer durations: =duration("PT" + waitMinutes + "M") - DMN decision tables: ranges, comparisons

Camunda implementa FEEL completo via feel-scala (~50K LOC, Scala language).

MVP necesita evaluar expressions. ¿Qué hacer?

FEEL — qué es exactamente

FEEL es spec del DMN standard (Decision Model and Notation). Features:

# Arithmetic
amount * 1.1

# Comparisons
amount > 100 and amount < 1000

# String operations
upper case("hello") = "HELLO"
string length(name)

# Date/time
date("2025-01-01")
duration("P1Y2M")
date and time("2025-01-01T12:00:00")

# Collections
[1, 2, 3, 4, 5][item > 2]  # filter
sum([1, 2, 3])
count(orders)

# Context (struct access)
customer.address.city

# Conditionals
if score > 80 then "A" else if score > 60 then "B" else "C"

# Boxed expressions (DMN)
{
  amount: 100,
  taxed: amount * 1.1
}

# Function definitions
function(x, y) x + y

Complejidad significativa. Feature-rich pero NO popular fuera de BPMN/DMN world.

Opciones consideradas

Opción 1: Build subset propio (FEEL-lite)

Implementar parser + evaluator para subset común:

# Soportar:
# - Literals: numbers, strings, booleans
# - Variables: customer.email
# - Arithmetic: + - * /
# - Comparisons: < > <= >= == !=
# - Logical: and or not
# - Collections: [1,2,3], .length
# - Conditionals: if-then-else

# NO soportar:
# - Date/time complex (use ISO strings)
# - DMN-specific syntax
# - Custom functions
# - Boxed expressions

Pros: - Full control - Performance optimizable - No vendor lock-in - Minimal dependencies

Cons: - ~3-6 meses de eng (parser + AST + evaluator + tests) - Inevitablemente incomplete vs feel-scala - Users que migran de Camunda hit edge cases - Maintenance permanente

LOC estimado: ~3-5K (parser, AST, evaluator, error handling, tests).

Opción 2: Integrate feel-scala como library

Run feel-scala como subprocess o JVM library:

Pros: - 100% Camunda compatibility - Feature complete - Maintained upstream

Cons: - JVM dependency (huge — 100MB+ JRE) - Scala interop (engine en Go/TS = boundary crossing) - Performance overhead (subprocess RPC ~1ms+) - Vendor lock-in to Camunda's implementation - License (Apache 2.0, OK)

Implementation: - Wrap feel-scala como REST microservice - Engine calls via HTTP per expression evaluation - Cache compiled expressions

Opción 3: Use JSONata

JSONata es expression language JSON-native:

$.amount > 100 and $.amount < 1000
$.customer.address.city
$sum($.items.price)

Pros: - Lightweight (~100KB) - JSON-native (perfect fit con variables) - Multiple language SDKs (JS, Python, Go, Rust) - Active community - Functional syntax

Cons: - NOT FEEL — users migrating from Camunda need conversion - BPMN spec compliance: rejected - Less features que FEEL

Opción 4: CEL (Common Expression Language)

CEL es de Google, production-grade:

amount > 100 && amount < 1000
customer.tier == "premium" && order.items.size() > 0
timestamp(joinDate) + duration("365d") > now

Pros: - Production-grade (used in Istio, Kubernetes, GCP IAM) - Multiple SDKs official (Go, Java, C++, with Python/TS community) - Predictable evaluation (no Turing-complete) - Type checking - Postgres extension available (postgres-cel) - Better tooling than FEEL

Cons: - NOT BPMN spec compliant - Migration de Camunda BPMN requires conversion - Less features for DMN specifically

Opción 5: Múltiples engines pluggable

Soportar varios:

<zeebe:taskDefinition type="my-task" />
<bpmn:conditionExpression language="feel">=amount > 100</bpmn:conditionExpression>
<bpmn:conditionExpression language="cel">amount > 100</bpmn:conditionExpression>
<bpmn:conditionExpression language="jsonata">$.amount > 100</bpmn:conditionExpression>

User elige por expression.

Pros: - Flexible - Migration de Camunda trivial (FEEL preserved)

Cons: - Multiple parsers/evaluators a mantener - Complexity de testing - User confusion (cuál usar?)

Decisión recomendada: CEL principal + FEEL-lite compatibility shim

Estrategia detallada

Phase 1: CEL como expression engine principal

Use CEL para nuevos workflows del MVP:

<bpmn:conditionExpression>
  <![CDATA[
    expressionLanguage="cel"
    expression="amount > 100 && customer.tier == 'premium'"
  ]]>
</bpmn:conditionExpression>

Implementation: - Go: github.com/google/cel-go - TypeScript: @cel-eval/runtime
- Python: celpy

Cero código propio de parser/evaluator.

Phase 1: FEEL-lite shim para Camunda compatibility

Para BPMN files importados de Camunda, soportar subset común de FEEL:

class FEELLite:
    """Subset de FEEL that covers ~80% of real-world Camunda usage."""

    SUPPORTED = {
        # Operators
        '+', '-', '*', '/',
        '<', '>', '<=', '>=', '==', '!=',
        'and', 'or', 'not',

        # Literals
        'number', 'string', 'boolean', 'null',

        # Path access
        'context.path.access',

        # Conditionals  
        'if/then/else',

        # Collections (basic)
        '[a, b, c]',
        '.length',
        '.contains(x)',
    }

    NOT_SUPPORTED = {
        # Reject these with clear error message
        'date()', 'time()', 'duration()',  # use ISO strings instead
        'function(...)',  # no user-defined functions
        'boxed expressions',  # DMN-specific
        'every', 'some',  # collection quantifiers
        'instance of',
    }

Implementation: ~2K LOC parser + evaluator. Suficiente para 80% de Camunda BPMN files migrated.

Phase 2: feel-scala integration (opcional)

Si demand real for full FEEL compatibility:

# Engine config
feel:
  engine: subprocess
  binary: /opt/feel-scala/bin/feel-evaluator
  fallback_to_feel_lite: true

Subprocess approach (no JVM in main process): - Engine sends expression + variables via stdin - feel-scala evaluator responds via stdout - Cache compiled expressions

Performance cost: ~1-5ms per expression. Acceptable for non-hot-path.

CEL — los detalles

Setup en engine (Go example)

import "github.com/google/cel-go/cel"

// One-time: compile expression
env, _ := cel.NewEnv(
    cel.Variable("amount", cel.IntType),
    cel.Variable("customer", cel.MapType(cel.StringType, cel.AnyType)),
)

ast, issues := env.Compile("amount > 100 && customer.tier == 'premium'")
if issues.Err() != nil {
    return invalidExpressionError(issues.Err())
}

program, _ := env.Program(ast)

// Many times: evaluate with variables
result, _, _ := program.Eval(map[string]interface{}{
    "amount": 150,
    "customer": map[string]interface{}{"tier": "premium"},
})
// result.Value() == true

CEL features for BPMN

// Gateway condition
amount > 100 && customer.tier == "premium"

// Input mapping
customer.email

// Output mapping  
{
  "orderId": response.id,
  "totalAmount": response.amount * 1.1
}

// Timer duration
duration("PT" + string(waitMinutes) + "M")

// Collection operations
orderItems.filter(i, i.quantity > 0).map(i, i.price * i.quantity).sum()

// String operations
customer.name.upperAscii()
customer.email.contains("@example.com")

// Conditionals
score > 80 ? "A" : (score > 60 ? "B" : "C")

CEL covers vast majority of BPMN expression needs.

Variable injection

Engine injects process variables como CEL bindings:

# Process instance variables
variables = {
    "customer": {"email": "alice@x.com", "tier": "premium"},
    "amount": 150,
    "items": [...]
}

# Build CEL context from variables
program.eval(variables)

Process variables naturally JSON → CEL maps trivially.

Migration de Camunda BPMN

User migrates Camunda BPMN file con FEEL expressions:

<!-- Camunda original -->
<bpmn:conditionExpression>=amount > 100</bpmn:conditionExpression>

<!-- After import to MVP -->
<bpmn:conditionExpression expressionLanguage="feel-lite">amount > 100</bpmn:conditionExpression>

Tool de conversion:

mvp-cli bpmn convert --from=camunda-feel --to=cel input.bpmn
# Tries best-effort FEEL → CEL conversion
# Reports unsupported expressions for manual fix

Common conversions: - =amount > 100 → CEL: amount > 100 - =customer.email → CEL: customer.email - =count(items) → CEL: items.size() - =substring(name, 1, 5) → CEL: name.substring(0, 5)

Some won't convert cleanly: - =date and time(...) → manual: use ISO strings - DMN-specific syntax → manual review

Performance comparison

Expression engine Compile Evaluate Memory
CEL ~1ms ~5μs cached Low
JSONata ~2ms ~20μs Low
FEEL-lite custom ~1ms ~10μs Low
feel-scala subprocess ~50ms ~1-5ms High (JVM)

CEL es clearly el best performance + features balance.

Caching expressions

Engine caches compiled expressions (parse once, evaluate many):

class ExpressionCache:
    def __init__(self):
        self.cache = LRUCache(maxsize=10000)

    async def evaluate(self, expression: str, variables: dict):
        if expression not in self.cache:
            program = compile_cel(expression)
            self.cache[expression] = program

        program = self.cache[expression]
        return program.eval(variables)

Compile cost amortized across many evaluations. Hot expressions effectively free.

Error handling

Bad expressions emit:

class ExpressionEvaluationError(Exception):
    pass

# When engine encounters bad expression at runtime:
try:
    result = await evaluator.evaluate(expression, variables)
except ExpressionEvaluationError as e:
    # Create incident (per ADR-007 pattern)
    await emit_incident(
        type='EXTRACT_VALUE_ERROR',
        message=f"Failed to evaluate expression: {e}",
        element_instance_key=element_instance.key
    )
    # Element execution paused, requires admin resolution

Sigue concepts/incident-management pattern.

Documentation for users

# Expressions en el MVP Engine

Workflows usan CEL (Common Expression Language) por default.

## Syntax básica

\`\`\`cel
amount > 100                    // comparison
customer.tier == "premium"      // path access
items.size() > 0                // collection
amount > 100 && verified        // logical
order.status in ["new", "paid"] // membership
\`\`\`

## Migrating from Camunda FEEL

Use `mvp-cli bpmn convert` para auto-conversion. Most FEEL expressions
convert directly. See [migration guide].

## Reference

Full CEL spec: https://github.com/google/cel-spec

Trade-offs documentados

Pro de CEL approach:

  • Production-grade engine (used at Google scale)
  • Multiple language SDKs
  • Predictable evaluation
  • Performance excellente
  • Cero build de parser

Con de CEL approach:

  • NOT 100% Camunda BPMN compliant
  • Migration requires conversion tool
  • Some FEEL features lost (DMN-specific especialmente)
  • Less ecosystem que FEEL en BPMN world

ADR implication

This decision should become ADR-022: Use CEL for expressions + FEEL-lite for Camunda compatibility.

Add to ADRs index when finalized.