diff --git a/README.md b/README.md
index be24aa0..ef06cf3 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick Start](#quick-start)
+- [Custom Functions](#custom-functions)
+- [Error Contract](#error-contract)
- [Configuration](#configuration)
- [Documentation](#documentation)
- [Contributing](#contributing)
@@ -23,27 +25,30 @@
## Overview
-Firefly Framework Rule Engine provides a powerful business rule evaluation system based on a custom YAML DSL. Rules are defined in YAML format and parsed into an Abstract Syntax Tree (AST) for efficient evaluation. The engine supports complex conditions, arithmetic operations, loop constructs, function calls, REST API calls, and JsonPath expressions.
+Firefly Framework Rule Engine provides a business rule evaluation system based on a custom YAML DSL. Rules are defined in YAML and parsed into an Abstract Syntax Tree (AST) for efficient evaluation. The engine supports rich conditions, arithmetic, loop constructs, function calls, REST API calls, JsonPath expressions, and a pluggable function-registry extension point.
-The project is structured as a multi-module build with five sub-modules: interfaces (DTOs and validation), models (database entities and repositories), core (DSL parser, evaluator, and services), SDK (client library), and web (REST controllers). It features Python code generation for rule compilation, batch evaluation, audit trail tracking, and caching for rule definitions.
+The project is structured as a multi-module Maven build with five sub-modules: `interfaces` (DTOs and validation), `models` (R2DBC entities and repositories), `core` (DSL parser, evaluator, services, function registry), `web` (Spring WebFlux REST controllers), and `sdk` (generated client). The engine ships with Python code generation for offline rule execution, batch evaluation, audit-trail tracking, and a dedicated cache layer.
-The YAML DSL supports variables, conditionals, loops (while, do-while, for-each), list operations, circuit breaker actions, and nested rule invocations, making it suitable for complex business rule scenarios such as credit scoring, eligibility checks, and pricing calculations.
+The YAML DSL supports input/computed/constant variable tiers, 30+ comparison operators, logical composition (and/or/not), loops (`forEach`, `while`, `do-while`), inline conditionals (`if/then/else`), 70+ built-in functions (financial, date, string, list, validation, REST, JSON, type-conversion), and circuit-breaker actions for early termination.
## Features
-- Custom YAML DSL with lexer, parser, and AST-based evaluation
-- Condition types: comparison, logical (AND/OR), expression-based
-- Action types: set, calculate, conditional, loops (while, do-while, for-each), function calls
-- Expression types: arithmetic, binary, unary, literals, variables, JsonPath, REST calls
-- Python code generation and compilation for rule optimization
-- Batch rule evaluation for processing multiple inputs
-- Rule definition CRUD with database persistence via R2DBC
-- Constants management for shared rule variables
-- Audit trail tracking for all rule evaluations
-- YAML DSL validation with syntax and naming convention checks
-- Caching for rule definitions and evaluation results
-- REST API controllers for evaluation, definitions, constants, audit, and validation
-- Reactive APIs using Project Reactor
+- Custom YAML DSL with dedicated lexer + recursive-descent parser + visitor-based evaluator
+- 30+ comparison operators including `between`, `in_list`, `matches`, `is_email`, `is_credit_score`, etc.
+- Logical composition (`and`, `or`, `not`) with short-circuit evaluation
+- Action types: `set`, `calculate`, `run`, `call`, arithmetic (`add`/`subtract`/`multiply`/`divide`), list ops (`append`/`prepend`/`remove`), `forEach`, `while`, `do-while`, `if/then/else`, `circuit_breaker`
+- 70+ built-in functions covering math, string, date, list, financial, validation, REST, JSON path, and type conversion
+- Pluggable function registry (`CustomFunctionRegistry`) — register your own `RuleFunction` beans and call them from rules
+- Constants tier loaded from the database with TTL caching; auto-detection of `UPPER_CASE` references in the AST
+- Reactive evaluation API on Project Reactor; synchronous visitor scheduled on `Schedulers.boundedElastic()` so it never blocks the Netty event loop
+- Python code generation for offline rule execution
+- Batch evaluation with bounded concurrency and per-request timeouts
+- Rule-definition CRUD with R2DBC persistence and a cached AST
+- Audit-trail tracking for every evaluation (correlated, PII-masked)
+- YAML DSL validation: syntax, naming-convention, dependency, function-existence
+- RFC 7807 problem-detail error responses; correlation IDs propagated across the chain
+- Fail-fast error contract: malformed rules, unknown functions, type-coercion errors, and bad regexes surface as `success=false` with precise diagnostics rather than silently flipping to the else branch
+- Spring WebFlux controllers; OpenAPI 3 / Swagger UI
## Requirements
@@ -61,70 +66,138 @@ The rule engine is a multi-module project. Include the modules you need:
org.fireflyframeworkfireflyframework-rule-engine-core
- 26.02.07
+ 26.05.07org.fireflyframeworkfireflyframework-rule-engine-interfaces
- 26.02.07
+ 26.05.07org.fireflyframeworkfireflyframework-rule-engine-sdk
- 26.02.07
+ 26.05.07
```
## Quick Start
+### Naming conventions
+The DSL is strict about variable naming so the engine can resolve names without ambiguity:
+
+| Tier | Convention | Example |
+| ---------------- | ------------ | ------------------------------------ |
+| Input variables | `camelCase` | `creditScore`, `annualIncome` |
+| Computed values | `snake_case` | `debt_to_income`, `risk_tier` |
+| Database constants | `UPPER_CASE` | `MIN_CREDIT_SCORE`, `MAX_DTI` |
+
+### Example rule (YAML DSL)
+
```yaml
-# Example rule definition (YAML DSL)
-name: credit-score-check
-version: 1
+name: "Credit Eligibility"
+description: "Two-stage credit and income gate"
+version: "1.0.0"
+
inputs:
- - name: creditScore
- type: number
- - name: income
- type: number
-
-rules:
- - name: evaluate-eligibility
- conditions:
- - field: creditScore
- operator: ">="
- value: 700
- - field: income
- operator: ">="
- value: 50000
- actions:
- - set:
- eligible: true
- tier: "premium"
- else:
- - set:
- eligible: false
- tier: "standard"
+ creditScore: "number"
+ annualIncome: "number"
+
+constants:
+ - code: MIN_CREDIT_SCORE
+ defaultValue: 700
+ - code: MIN_INCOME
+ defaultValue: 50000
+
+when:
+ - creditScore at_least MIN_CREDIT_SCORE
+ - annualIncome at_least MIN_INCOME
+
+then:
+ - calculate debt_to_income as 0 # placeholder; real rules would compute this
+ - set tier to if_else(creditScore at_least 800, "PRIME", "PREFERRED")
+ - set eligible to true
+
+else:
+ - set tier to "STANDARD"
+ - set eligible to false
+
+output:
+ eligible: eligible
+ tier: tier
```
+### Calling the engine from Java
+
```java
@Service
public class CreditCheckService {
private final RulesEvaluationService evaluationService;
- public Mono evaluate(Map inputs) {
- RulesEvaluationRequestDTO request = new RulesEvaluationRequestDTO();
- request.setRuleCode("credit-score-check");
- request.setInputs(inputs);
- return evaluationService.evaluate(request);
+ public Mono evaluate(Map inputData) {
+ RuleEvaluationByCodeRequestDTO request = RuleEvaluationByCodeRequestDTO.builder()
+ .ruleDefinitionCode("credit_eligibility_v1")
+ .inputData(inputData)
+ .build();
+ return evaluationService.evaluateRuleByCode(request, exchange);
+ }
+}
+```
+
+The same evaluation is also reachable via REST: `POST /api/v1/rules/evaluate/direct` (Base64-encoded YAML), `/evaluate/plain` (raw YAML), or `/evaluate/by-code` (stored rule code).
+
+## Custom Functions
+
+You can extend the DSL with your own functions without modifying the engine:
+
+```java
+@Configuration
+class MyRulesConfig {
+
+ @Bean
+ CommandLineRunner registerCustomFunctions(CustomFunctionRegistry registry) {
+ return args -> {
+ registry.register("regional_risk", a ->
+ Set.of("CA", "NY").contains(a[0]) ? 10 : 0);
+ registry.register("fraud_score", a ->
+ fraudService.score(String.valueOf(a[0])));
+ };
}
}
```
+Then in a rule:
+
+```yaml
+then:
+ - run risk_bump as regional_risk(region)
+ - run fraud as fraud_score(applicantId)
+```
+
+Custom functions are checked **before** the built-in catalog (so they can shadow a built-in if you choose). Names are matched case-insensitively. The same function is callable from both expression contexts (`run`/`calculate`) and `call`-action contexts.
+
+## Error Contract
+
+The engine fails loud by design — errors are never silently swallowed:
+
+| Situation | Result |
+| -------------------------------------------- | --------------------------------------------------------------------------- |
+| Unknown function name | `IllegalArgumentException` -> rule reports `success=false` with the name |
+| Non-numeric string in arithmetic | `IllegalArgumentException` naming the operand |
+| Bad regex pattern in `matches` | `IllegalArgumentException` naming the pattern |
+| Missing bean property | `IllegalArgumentException` naming the class + property |
+| Unknown `is_valid` validation type | `IllegalArgumentException` listing supported types |
+| Action throws during execution | Rule reports `success=false` with action index + debug string + cause |
+| Condition throws during evaluation | Rule reports `success=false` (does not silently flip to the else branch) |
+| `circuit_breaker` action triggered | Rule reports `success=true` with `circuitBreakerTriggered=true` + message |
+| REST function HTTP failure | Returns a structured `{success:false, error:true, message}` map (chain-friendly) |
+| `getCachedAST` / cache read failure | Treated as cache miss (parse path); logged via `doOnError` |
+| `set computed_var to null` (e.g., `json_get` missing path) | Variable is stored as null; the rule succeeds |
+
## Configuration
```yaml
diff --git a/docs/architecture.md b/docs/architecture.md
index 6adaa22..74806c9 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1158,16 +1158,37 @@ The `RestCallServiceImpl` includes comprehensive URL validation before executing
- **Timing-safe comparison**: Hash verification uses `hmac.compare_digest()` to prevent timing attacks
### 4. Safe Code Evaluation
-- **No unsafe reflection**: `ExpressionEvaluator.getPropertyValue()` uses public getter methods only; `field.setAccessible(true)` is not used
-- **Division/modulo by zero**: `ExpressionEvaluator` and `ActionExecutor` throw `ArithmeticException` instead of returning null or silently failing
-- **Short-circuit evaluation**: AND/OR operators use lazy evaluation to prevent unnecessary side effects
-- **Thread-safe code generation**: `PythonCodeGenerator` uses `ThreadLocal` for mutable state to ensure safe concurrent compilation
-
-### 5. Error Handling
-- Graceful degradation for missing constants
-- Circuit breaker pattern for external dependencies
-- Comprehensive logging for audit trails
-- Null-safe audit context extraction (defaults to "system" when no web exchange is available)
+- **No unsafe reflection**: `ExpressionEvaluator.getPropertyValue()` uses public getter methods only (`getX` / `isX`); `field.setAccessible(true)` is never called. A missing getter throws `IllegalArgumentException` with the class + property name rather than silently returning null.
+- **Division/modulo by zero**: `ExpressionEvaluator` and `ActionExecutor` throw `ArithmeticException` rather than returning null or silently failing.
+- **Short-circuit evaluation**: AND/OR operators use lazy evaluation to prevent unnecessary side effects.
+- **Thread-safe code generation**: `PythonCodeGenerator` uses `ThreadLocal` for mutable state to ensure safe concurrent compilation.
+
+### 5. Error Handling (Fail-Loud Contract)
+
+The engine is intentionally non-silent. Errors propagate to the rule's `success=false`
+result with a precise diagnostic message rather than being swallowed and producing a
+plausible-but-wrong output.
+
+| Source of failure | Behaviour |
+| ------------------------------------------ | ------------------------------------------------------------------------ |
+| Unknown function name | `IllegalArgumentException` -> `success=false` |
+| Non-numeric string in arithmetic | `IllegalArgumentException` naming the operand |
+| Bad regex pattern in `matches` | `IllegalArgumentException` naming the pattern |
+| Missing bean property | `IllegalArgumentException` naming class + property |
+| Unknown `is_valid` validation type | `IllegalArgumentException` listing supported types |
+| Unknown `dateadd`/`datediff` unit | `IllegalArgumentException` listing supported units |
+| Action throws mid-execution | Rule reports `success=false` with action index + debug string + cause |
+| Condition throws mid-evaluation | Rule reports `success=false` (does not silently flip to the else branch) |
+| `circuit_breaker` action triggered | Rule reports `success=true` with `circuitBreakerTriggered=true` |
+| Required constant missing in database | `success=false` listing the missing codes |
+| REST function HTTP failure | Structured error map `{success:false, error:true, message:...}` (intentional chain-friendly contract; rules can branch on `response.success`) |
+| Cache read failure | Treated as cache miss; logged via `doOnError` |
+
+Surrounding mechanisms:
+- Graceful degradation for missing **optional** constants (with explicit `defaultValue`).
+- Circuit breaker pattern for external dependencies.
+- Comprehensive audit trail (every evaluation logged with correlation ID).
+- Null-safe audit context extraction (defaults to "system" when no web exchange is available).
### 6. Cache Integrity
- Cache invalidation on all CRUD operations (create, update, delete) for rule definitions
diff --git a/docs/b2b-credit-scoring-tutorial.md b/docs/b2b-credit-scoring-tutorial.md
index cb2e664..d87e805 100644
--- a/docs/b2b-credit-scoring-tutorial.md
+++ b/docs/b2b-credit-scoring-tutorial.md
@@ -35,6 +35,7 @@ By the end of this tutorial, you'll understand:
Every Firefly rule follows a consistent structure. Let's start with the basic template:
+
```yaml
# Required metadata
name: "Rule Name"
@@ -236,6 +237,7 @@ constants:
Our B2B credit scoring will use multiple sequential rules to build a comprehensive assessment. This approach mirrors real-world credit evaluation processes where different aspects are analyzed in stages.
+
```yaml
# Multi-stage evaluation using sequential rules
rules:
@@ -276,8 +278,8 @@ rules:
)
# Calculate data quality indicators
- - calculate revenue_variance as abs(annualRevenue - verifiedAnnualRevenue) / verifiedAnnualRevenue
- - calculate deposit_variance as abs(avgMonthlyDeposits - monthlyRevenue) / monthlyRevenue
+ - run revenue_variance as abs(annualRevenue - verifiedAnnualRevenue) / verifiedAnnualRevenue
+ - run deposit_variance as abs(avgMonthlyDeposits - monthlyRevenue) / monthlyRevenue
# Overall data completeness and quality check
- set data_validation_complete to (
diff --git a/docs/common-patterns-guide.md b/docs/common-patterns-guide.md
index 7c88c80..32ecdcf 100644
--- a/docs/common-patterns-guide.md
+++ b/docs/common-patterns-guide.md
@@ -104,6 +104,7 @@ output:
**Use Case**: Validating input data before processing
+
```yaml
name: "Application Data Validation"
description: "Validate customer application data"
@@ -407,6 +408,7 @@ output:
**Use Case**: Calculating risk scores from multiple factors
+
```yaml
name: "Credit Risk Assessment"
description: "Calculate risk score from multiple financial factors"
@@ -550,7 +552,7 @@ rules:
- set stage to "COMPLETED"
- set final_decision_value to decision
- - calculate processed_at as now()
+ - run processed_at as now()
- set processing_complete to true
else:
- set decision to "REJECTED"
@@ -630,7 +632,7 @@ then:
# Store metadata
- set enrichment_sources to sources
- set enrichment_quality to data_quality
- - calculate enrichment_timestamp as now()
+ - run enrichment_timestamp as now()
- set enrichment_customer_id to customerId
else:
diff --git a/docs/developer-guide.md b/docs/developer-guide.md
index 8e82c5f..c0637ef 100644
--- a/docs/developer-guide.md
+++ b/docs/developer-guide.md
@@ -207,34 +207,36 @@ org.fireflyframework.rules.core.dsl.ast/
│ └── BaseParser.java # Common parsing utilities
├── expression/ # Expression AST nodes
│ ├── Expression.java # Base expression class
-│ ├── BinaryExpression.java # Binary operations (+, -, *, /, etc.)
+│ ├── BinaryExpression.java # Binary operations (+, -, *, /, %, **, comparisons)
│ ├── UnaryExpression.java # Unary operations (-, !, validation ops)
-│ ├── LiteralExpression.java # Literal values (numbers, strings, booleans)
-│ ├── VariableExpression.java # Variable references
+│ ├── LiteralExpression.java # Literal values (numbers, strings, booleans, arrays)
+│ ├── VariableExpression.java # Variable references with property/index access
│ ├── FunctionCallExpression.java # Function calls with parameters
-│ ├── ArithmeticExpression.java # Complex arithmetic expressions
-│ ├── JsonPathExpression.java # JSON path queries
-│ ├── RestCallExpression.java # REST API calls
│ ├── BinaryOperator.java # Binary operator enumeration
│ ├── UnaryOperator.java # Unary operator enumeration
│ └── ExpressionType.java # Expression type enumeration
├── condition/ # Condition AST nodes
│ ├── Condition.java # Base condition class
-│ ├── ComparisonCondition.java # Comparison operations (>, <, ==, etc.)
+│ ├── ComparisonCondition.java # Comparison operations (>, <, ==, between, in_list, etc.)
│ ├── LogicalCondition.java # Logical operations (AND, OR, NOT)
│ ├── ExpressionCondition.java # Expression-based conditions
│ └── ComparisonOperator.java # Comparison operator enumeration
├── action/ # Action AST nodes
│ ├── Action.java # Base action class
-│ ├── SetAction.java # Variable assignment (set var to value)
-│ ├── CalculateAction.java # Arithmetic calculations
-│ ├── AssignmentAction.java # Assignment operations (=, +=, etc.)
-│ ├── FunctionCallAction.java # Function execution
-│ ├── ConditionalAction.java # If-then-else actions
-│ ├── ArithmeticAction.java # Arithmetic actions
-│ ├── ListAction.java # List operations (append, prepend, etc.)
-│ ├── CircuitBreakerAction.java # Execution control
-│ └── AssignmentOperator.java # Assignment operator enumeration
+│ ├── SetAction.java # Variable assignment (`set var to value`)
+│ ├── CalculateAction.java # Pure-math arithmetic (`calculate var as expr`)
+│ ├── RunAction.java # Function-call assignment (`run var as fn(...)`)
+│ ├── FunctionCallAction.java # Standalone function execution (`call fn with [...]`)
+│ ├── ConditionalAction.java # Inline if-then-else as an action
+│ ├── ArithmeticAction.java # `add`/`subtract`/`multiply`/`divide` X to/from/by var
+│ ├── ListAction.java # List operations (append, prepend, remove)
+│ ├── CircuitBreakerAction.java # Early termination with a structured message
+│ ├── ForEachAction.java # `forEach item in items: ...`
+│ ├── WhileAction.java # `while condition: ...`
+│ └── DoWhileAction.java # `do: ... while condition`
+├── function/ # Extension point for user-defined functions
+│ ├── RuleFunction.java # Functional interface: `Object apply(Object[] args)`
+│ └── CustomFunctionRegistry.java # @Component holding registered functions
├── visitor/ # Visitor pattern implementations
│ ├── EvaluationContext.java # Execution context and state
│ ├── ExpressionEvaluator.java # Expression evaluation visitor
@@ -663,64 +665,31 @@ public abstract class ASTNode {
```
ASTNode (abstract base)
├── Expression (abstract)
-│ ├── LiteralExpression
-│ │ ├── NumberLiteral (integers, decimals)
-│ │ ├── StringLiteral (quoted strings)
-│ │ ├── BooleanLiteral (true/false)
-│ │ └── NullLiteral (null values)
-│ ├── VariableExpression (variable references)
-│ ├── BinaryExpression
-│ │ ├── ArithmeticExpression (+, -, *, /, %, **)
-│ │ ├── ComparisonExpression (>, <, ==, !=, >=, <=)
-│ │ ├── LogicalExpression (AND, OR)
-│ │ ├── StringExpression (contains, starts_with, ends_with)
-│ │ └── ListExpression (in, not_in)
-│ ├── UnaryExpression
-│ │ ├── ArithmeticUnary (-, +)
-│ │ ├── LogicalUnary (NOT, !)
-│ │ └── ValidationUnary (is_positive, is_email, is_phone, etc.)
-│ ├── FunctionCallExpression
-│ │ ├── MathFunctions (abs, round, ceil, floor, etc.)
-│ │ ├── StringFunctions (length, substring, upper, lower, etc.)
-│ │ ├── DateFunctions (now, date_add, date_diff, etc.)
-│ │ └── CustomFunctions (user-defined functions)
-│ ├── ArithmeticExpression (complex multi-operand arithmetic)
-│ ├── JsonPathExpression (JSON path queries)
-│ └── RestCallExpression (REST API calls)
+│ ├── LiteralExpression # numbers, strings, booleans, null, array literals
+│ ├── VariableExpression # variable refs with optional property path / index access
+│ ├── BinaryExpression # +, -, *, /, %, **, comparisons, and/or, contains, starts_with, etc.
+│ ├── UnaryExpression # -, +, NOT, EXISTS, IS_NULL, IS_EMAIL, IS_POSITIVE, etc.
+│ └── FunctionCallExpression # math, string, date, list, financial, validation, REST, JSON funcs
├── Condition (abstract)
-│ ├── ComparisonCondition
-│ │ ├── SimpleComparison (var > value)
-│ │ ├── BetweenCondition (var between min and max)
-│ │ ├── InCondition (var in [list])
-│ │ └── ValidationCondition (var is_positive)
-│ ├── LogicalCondition
-│ │ ├── AndCondition (condition1 AND condition2)
-│ │ ├── OrCondition (condition1 OR condition2)
-│ │ └── NotCondition (NOT condition)
-│ └── ExpressionCondition (expression-based conditions)
+│ ├── ComparisonCondition # `>=`, `at_least`, `between ... and ...`, `in_list [...]`, `is_email`, etc.
+│ ├── LogicalCondition # AND / OR / NOT composition
+│ └── ExpressionCondition # wraps any boolean-valued Expression
└── Action (abstract)
- ├── SetAction (set variable to value)
- ├── CalculateAction (calculate variable as expression)
- ├── AssignmentAction
- │ ├── SimpleAssignment (var = value)
- │ ├── AddAssignment (var += value)
- │ ├── SubtractAssignment (var -= value)
- │ ├── MultiplyAssignment (var *= value)
- │ └── DivideAssignment (var /= value)
- ├── FunctionCallAction (call function with parameters)
- ├── ConditionalAction
- │ ├── IfAction (if condition then actions)
- │ ├── IfElseAction (if condition then actions else actions)
- │ └── SwitchAction (switch-case logic)
- ├── ArithmeticAction (arithmetic operations as actions)
- ├── ListAction
- │ ├── AppendAction (append to list)
- │ ├── PrependAction (prepend to list)
- │ ├── RemoveAction (remove from list)
- │ └── ClearAction (clear list)
- └── CircuitBreakerAction (execution control and error handling)
+ ├── SetAction # `set var to value`
+ ├── CalculateAction # `calculate var as `
+ ├── RunAction # `run var as `
+ ├── FunctionCallAction # `call fn with [args]`
+ ├── ConditionalAction # `if cond then actions [else actions]`
+ ├── ArithmeticAction # `add X to var`, `subtract X from var`, `multiply var by X`, `divide var by X`
+ ├── ListAction # `append X to list`, `prepend X to list`, `remove X from list`
+ ├── ForEachAction # `forEach item[, index] in items: actions`
+ ├── WhileAction # `while cond: actions`
+ ├── DoWhileAction # `do: actions while cond`
+ └── CircuitBreakerAction # `circuit_breaker "MESSAGE"` -- early termination
```
+> **Note:** The DSL was simplified in 2026-05 to remove orphan AST classes that were never produced by the parser. The compound-assignment family (`+=`, `-=`, etc.) and the multi-operand `ArithmeticExpression` n-ary form are no longer present. Use `add`/`subtract`/`multiply`/`divide` arithmetic actions or `calculate`/`run` with `+`/`-`/`*`/`/` for the same outcomes.
+
### 🔢 **Expression Nodes: Representing Computable Values**
**What is an Expression?**
@@ -1316,11 +1285,9 @@ public class ActionParser extends BaseParser {
return parseConditionalAction();
}
- // Handle arithmetic actions (var += value)
- if (check(TokenType.IDENTIFIER) && checkNext(TokenType.ASSIGN, TokenType.PLUS_ASSIGN,
- TokenType.MINUS_ASSIGN, TokenType.MULTIPLY_ASSIGN)) {
- return parseAssignmentAction();
- }
+ // Arithmetic actions are emitted by `add`/`subtract`/`multiply`/`divide` keywords
+ // (parseArithmeticAction), not by `=` / `+=` operators -- the latter are not part
+ // of the action DSL.
throw error("Expected action statement");
}
@@ -1400,29 +1367,29 @@ The `ASTVisitor` interface is the heart of the visitor pattern implementation:
public interface ASTVisitor {
// Expression visitors - handle value computation
- T visitBinaryExpression(BinaryExpression node); // a + b, a > b, etc.
- T visitUnaryExpression(UnaryExpression node); // -a, !a, is_positive(a)
- T visitVariableExpression(VariableExpression node); // creditScore, income
- T visitLiteralExpression(LiteralExpression node); // 42, "hello", true
- T visitFunctionCallExpression(FunctionCallExpression node); // max(a, b)
- T visitArithmeticExpression(ArithmeticExpression node); // Complex arithmetic
- T visitJsonPathExpression(JsonPathExpression node); // $.user.name
- T visitRestCallExpression(RestCallExpression node); // REST API calls
+ T visitBinaryExpression(BinaryExpression node); // a + b, a > b, a and b, etc.
+ T visitUnaryExpression(UnaryExpression node); // -a, !a, is_positive(a)
+ T visitVariableExpression(VariableExpression node); // creditScore, user.profile.name
+ T visitLiteralExpression(LiteralExpression node); // 42, "hello", true, [1,2,3]
+ T visitFunctionCallExpression(FunctionCallExpression node); // max(a, b), if_else(...), coalesce(...), rest_get(...), json_get(...)
// Condition visitors - handle boolean logic
- T visitComparisonCondition(ComparisonCondition node); // a > b
- T visitLogicalCondition(LogicalCondition node); // a AND b
- T visitExpressionCondition(ExpressionCondition node); // Expression as condition
+ T visitComparisonCondition(ComparisonCondition node); // a > b, a between x and y, etc.
+ T visitLogicalCondition(LogicalCondition node); // a AND b, a OR b, NOT a
+ T visitExpressionCondition(ExpressionCondition node); // any Expression as a condition
// Action visitors - handle state changes
T visitSetAction(SetAction node); // set var to value
- T visitCalculateAction(CalculateAction node); // calculate var as expr
- T visitAssignmentAction(AssignmentAction node); // var = value, var += value
- T visitFunctionCallAction(FunctionCallAction node); // call function()
- T visitConditionalAction(ConditionalAction node); // if-then-else
- T visitArithmeticAction(ArithmeticAction node); // Arithmetic as action
- T visitListAction(ListAction node); // List operations
- T visitCircuitBreakerAction(CircuitBreakerAction node); // Error handling
+ T visitCalculateAction(CalculateAction node); // calculate var as expr (pure-math only)
+ T visitRunAction(RunAction node); // run var as fn(...) / json_get(...) / rest_*(...)
+ T visitFunctionCallAction(FunctionCallAction node); // call fn with [args]
+ T visitConditionalAction(ConditionalAction node); // if cond then ... else ...
+ T visitArithmeticAction(ArithmeticAction node); // add/subtract/multiply/divide ... to/from/by var
+ T visitListAction(ListAction node); // append/prepend/remove ... to/from list
+ T visitCircuitBreakerAction(CircuitBreakerAction node); // circuit_breaker "MESSAGE"
+ T visitForEachAction(ForEachAction node); // forEach item[, index] in items: actions
+ T visitWhileAction(WhileAction node); // while cond: actions
+ T visitDoWhileAction(DoWhileAction node); // do: actions while cond
}
```
@@ -1578,44 +1545,33 @@ public class ActionExecutor implements ASTVisitor {
}
@Override
- public Void visitAssignmentAction(AssignmentAction node) {
- // Step 1: Evaluate the new value
+ public Void visitArithmeticAction(ArithmeticAction node) {
+ // Equivalent of an "in-place" compound assignment, expressed via dedicated keywords
+ // in the DSL (`add X to var`, `subtract X from var`, etc.). Both operands must be
+ // numeric; non-numeric operands raise IllegalArgumentException so authoring bugs
+ // surface immediately rather than silently no-op'ing.
Object value = node.getValue().accept(expressionEvaluator);
+ Object current = context.getVariable(node.getVariableName());
- // Step 2: Apply the assignment operator
- switch (node.getOperator()) {
- case ASSIGN -> {
- // Simple assignment: var = value
- context.setComputedVariable(node.getVariableName(), value);
- }
- case ADD_ASSIGN -> {
- // Addition assignment: var += value
- Object currentValue = context.getVariable(node.getVariableName());
- if (currentValue instanceof Number && value instanceof Number) {
- // Numeric addition
- BigDecimal current = toBigDecimal(currentValue);
- BigDecimal addValue = toBigDecimal(value);
- context.setComputedVariable(node.getVariableName(), current.add(addValue));
- } else {
- // String concatenation
- context.setComputedVariable(node.getVariableName(),
- currentValue.toString() + value.toString());
- }
- }
- case SUBTRACT_ASSIGN -> {
- // Subtraction assignment: var -= value
- Object currentValue = context.getVariable(node.getVariableName());
- if (currentValue instanceof Number && value instanceof Number) {
- BigDecimal current = toBigDecimal(currentValue);
- BigDecimal subValue = toBigDecimal(value);
- context.setComputedVariable(node.getVariableName(), current.subtract(subValue));
- } else {
- throw new ASTException("Cannot subtract non-numeric values");
- }
- }
- // ... other assignment operators
+ if (!(current instanceof Number) || !(value instanceof Number)) {
+ throw new IllegalArgumentException(
+ "Arithmetic action requires numeric operands");
}
+ BigDecimal currentNum = toBigDecimal(current);
+ BigDecimal valueNum = toBigDecimal(value);
+ BigDecimal result = switch (node.getOperation()) {
+ case ADD -> currentNum.add(valueNum);
+ case SUBTRACT -> currentNum.subtract(valueNum);
+ case MULTIPLY -> currentNum.multiply(valueNum);
+ case DIVIDE -> {
+ if (valueNum.signum() == 0) {
+ throw new ArithmeticException("Division by zero in arithmetic action");
+ }
+ yield currentNum.divide(valueNum, 10, RoundingMode.HALF_UP);
+ }
+ };
+ context.setComputedVariable(node.getVariableName(), result);
return null;
}
diff --git a/docs/governance-guidelines.md b/docs/governance-guidelines.md
index 9a7e783..2ff1684 100644
--- a/docs/governance-guidelines.md
+++ b/docs/governance-guidelines.md
@@ -243,12 +243,14 @@ else:
```
**For Intermediate and Advanced:**
+
+Use the `circuit_breaker` *action* inside `then:` to short-circuit a rule when a
+downstream dependency or risk signal trips:
+
+
```yaml
-# Add circuit breakers for external dependencies
-circuit_breaker:
- enabled: true
- failure_threshold: 3
- timeout_duration: "10s"
+then:
+ - if downstream_failure_count at_least 3 then circuit_breaker "DOWNSTREAM_UNAVAILABLE"
```
---
diff --git a/docs/quick-start-guide.md b/docs/quick-start-guide.md
index 2481ddb..dcafe30 100644
--- a/docs/quick-start-guide.md
+++ b/docs/quick-start-guide.md
@@ -290,8 +290,10 @@ then:
- set current to startValue
- set count to 0
- # Always executes at least once
- - do: multiply current by 2; add 1 to count while current less_than maxValue
+ # Always executes at least once.
+ # Grammar reminder: arithmetic actions are ``,
+ # so "multiply current by 2" is written `multiply 2 by current`.
+ - do: multiply 2 by current; add 1 to count while current less_than maxValue
output:
current: number
@@ -385,6 +387,7 @@ if condition then action
```
### Basic Structure Template
+
```yaml
name: "Your Rule Name"
description: "What this rule does"
diff --git a/docs/yaml-dsl-reference.md b/docs/yaml-dsl-reference.md
index e7da86b..19ae18b 100644
--- a/docs/yaml-dsl-reference.md
+++ b/docs/yaml-dsl-reference.md
@@ -78,16 +78,18 @@ metadata: # Optional: Additional metadata
constants: # Optional: Constants with defaults
- code: CONSTANT_NAME
defaultValue: value
-
-circuit_breaker: # Optional: Resilience configuration
- enabled: true
- failure_threshold: 5
- timeout_duration: "30s"
```
+> Note: early versions accepted a top-level `circuit_breaker:` configuration block
+> (`enabled`, `failure_threshold`, `timeout_duration`, `recovery_timeout`). It was parsed
+> but never enforced at runtime and is no longer accepted. Use the
+> `circuit_breaker "MESSAGE"` *action* (described under "Action Syntax") for controlled
+> early termination within a rule.
+
### Logic Sections (Choose One)
**Simple Syntax:**
+
```yaml
when: [conditions] # Simple condition list
then: [actions] # Actions when true
@@ -95,6 +97,7 @@ else: [actions] # Actions when false (optional)
```
**Complex Syntax:**
+
```yaml
conditions: # Structured condition blocks
if: {condition_structure}
@@ -103,6 +106,7 @@ conditions: # Structured condition blocks
```
**Multiple Rules:**
+
```yaml
rules: # Array of sub-rules
- name: "Sub-rule 1"
@@ -133,7 +137,6 @@ The DSL uses specific reserved keywords that have special meaning in the parser.
| | `conditions` | ❌* | Complex condition blocks | `conditions: {if: {...}, then: {...}}` |
| | `rules` | ❌* | Multiple sequential rules | `rules: [{name: "Rule 1", when: [...]}]` |
| **Advanced Features** | `metadata` | ❌ | Additional metadata | `metadata: {tags: ["credit"], author: "Team"}` |
-| | `circuit_breaker` | ❌ | Resilience configuration | `circuit_breaker: {enabled: true}` |
*One of `when`/`then`, `conditions`, or `rules` is required for logic definition.
@@ -276,29 +279,29 @@ when:
- (monthlyIncome is_positive AND annualIncome is_positive)
```
-**NEW: Validation Operators in Expressions**
+**Validation Operators in Expressions**
-Validation operators can now be used in complex expressions, not just simple conditions:
+Validation operators can be used in any expression context, not just `when:` clauses:
+
```yaml
then:
- # Set variables using validation operators in expressions
- - set has_valid_contact to (email is_email AND phone is_phone)
- - set financial_data_complete to (
- monthlyRevenue is_positive AND
- monthlyExpenses is_positive AND
- annualIncome is_not_null
- )
-
- # Calculate boolean results with validation operators
- - calculate data_quality_score as (
- (customerName is_not_empty ? 25 : 0) +
- (email is_email ? 25 : 0) +
- (phone is_phone ? 25 : 0) +
- (ssn is_ssn ? 25 : 0)
- )
+ # Validation operators inside `set`-to-boolean expressions
+ - set has_valid_contact to (email is_email and phone is_phone)
+ - set financial_data_complete to (monthlyRevenue is_positive and monthlyExpenses is_positive and annualIncome is_not_null)
+
+ # Score each field independently with the inline `if_else` function, then sum
+ - run name_score as if_else(customerName is_not_empty, 25, 0)
+ - run email_score as if_else(email is_email, 25, 0)
+ - run phone_score as if_else(phone is_phone, 25, 0)
+ - run ssn_score as if_else(ssn is_ssn, 25, 0)
+ - calculate data_quality_score as name_score + email_score + phone_score + ssn_score
```
+> **Note:** The engine does not have a C-style ternary `? :` operator; use the
+> `if_else(condition, then_value, else_value)` built-in function instead. Both arguments
+> are evaluated eagerly (no short-circuit).
+
@@ -312,18 +315,23 @@ then:
| | `/` | `/` | Division | `expression / expression` | `monthlyDebt / annualIncome` |
| | `%` | `%` | Modulo (remainder) | `expression % expression` | `amount % 100` |
| | `**` | `**` | Power/Exponentiation | `base ** exponent` | `(1 + rate) ** years` |
-| **Arithmetic Actions** | `add` | - | Add to variable | `add value to variable` | `add 10 to base_score` |
-| | `subtract` | - | Subtract from variable | `subtract value from variable` | `subtract penalty from total_score` |
-| | `multiply` | - | Multiply variable | `multiply variable by value` | `multiply risk_factor by 1.5` |
-| | `divide` | - | Divide variable | `divide variable by value` | `divide monthly_payment by 2` |
-| **Helper Keywords** | `to` | - | Assignment target | `set variable to value` | `set approval_status to "APPROVED"` |
-| | `as` | - | Calculation target | `calculate variable as expression` | `calculate debt_ratio as monthlyDebt / annualIncome` |
-| | `with` | - | Function parameters | `call function with [args]` | `call log with ["Message", "INFO"]` |
-| | `from` | - | Subtraction source | `subtract value from variable` | `subtract penalty from total_score` |
-| | `by` | - | Factor for multiply/divide | `multiply/divide variable by value` | `multiply risk_factor by 1.5` |
-| | `and` | - | Range separator | `value between min and max` | `age between 18 and 65` |
+| **Arithmetic Actions** | `add` | - | Add value to variable | `add VALUE to VARIABLE` | `add 10 to base_score` |
+| | `subtract` | - | Subtract value from variable | `subtract VALUE from VARIABLE` | `subtract penalty from total_score` |
+| | `multiply` | - | Multiply target variable by factor | `multiply VALUE by VARIABLE` | `multiply 1.5 by risk_factor` |
+| | `divide` | - | Divide target variable by divisor | `divide VALUE by VARIABLE` | `divide 2 by monthly_payment` |
+| **Helper Keywords** | `to` | - | Assignment target | `set VARIABLE to VALUE` | `set approval_status to "APPROVED"` |
+| | `as` | - | Calculation target | `calculate VARIABLE as EXPRESSION` | `calculate debt_ratio as monthlyDebt / annualIncome` |
+| | `with` | - | Function parameters | `call FUNCTION with [args]` | `call log with ["Message", "INFO"]` |
+| | `from` | - | Subtraction source | `subtract VALUE from VARIABLE` | `subtract penalty from total_score` |
+| | `by` | - | Factor for multiply/divide | `multiply VALUE by VARIABLE` | `multiply 1.5 by risk_factor` |
+| | `and` | - | Range separator | `VALUE between MIN and MAX` | `age between 18 and 65` |
+
+> **Grammar peculiarity for `multiply` / `divide`:** the value comes *first*, then the variable.
+> All four arithmetic actions follow the same shape: ``.
+> Read `multiply 1.5 by risk_factor` as "apply ×1.5 to `risk_factor`".
**Arithmetic Expression Examples:**
+
```yaml
then:
# Basic arithmetic in expressions
@@ -333,10 +341,11 @@ then:
- calculate remainder as loanAmount % 1000
# Arithmetic actions (modify existing variables)
+ # Grammar:
- add 50 to credit_score
- subtract late_fee from account_balance
- - multiply risk_score by 1.2
- - divide monthly_payment by 2
+ - multiply 1.2 by risk_score
+ - divide 2 by monthly_payment
# Complex expressions
- calculate debt_to_income as (monthlyDebt + proposedPayment) / (annualIncome / 12)
@@ -1017,19 +1026,35 @@ conditions:
- run uppercase as upper(name)
- run lowercase as lower(email)
- run trimmed as trim(input_text)
-- calculate length as length(description)
+- run name_length as length(description)
# Date/time functions
-- calculate current_date as now()
-- calculate formatted_date as format_date(date_value, "yyyy-MM-dd")
-- calculate age_years as calculate_age(birth_date)
-
-# Validation functions
-- calculate is_valid_email as validate_email(email_address)
-- calculate is_valid_phone as validate_phone(phone_number)
-- calculate is_business_day as is_business_day(date_value)
+- run current_date as now()
+- run today_date as today()
+- run formatted_date as format_date(date_value, "yyyy-MM-dd")
+- run pretty_date as format_date(date_value, "dd MMM yyyy")
+- run age_years as calculate_age(birth_date) # from today
+- run age_at_event as calculate_age(birth_date, event_date)
+- run plus_thirty as dateadd(today_date, 30, "days")
+- run days_between as datediff(start_date, end_date, "days")
+
+# Validation functions (function-call form complements the `is_email`/`is_phone` operators)
+- run email_ok as validate_email(email_address)
+- run phone_ok as validate_phone(phone_number)
+- run is_business_day_today as is_business_day(today_date)
+- run any_check as is_valid(value, "email") # 12 known types; unknown -> error
+
+# Null-handling & conditional helpers (DSL primitives)
+- run preferred_name as coalesce(nickname, full_name, "Anonymous") # first non-null wins
+- run tier as if_else(creditScore at_least 750, "PRIME", "STANDARD") # inline ternary
+- run within_window as is_in_range(score, 600, 850) # function form of `between`
```
+> **Important:** Function calls and REST/JSON expressions are only legal in `run` actions and in
+> expression contexts (function arguments, conditions, output mappings). The `calculate` action
+> is restricted to pure mathematical expressions (`+ - * / % **`) on numeric inputs --
+> attempting to use a function call inside `calculate` raises a clean validation error.
+
### REST Call Expressions
```yaml
@@ -1042,12 +1067,18 @@ conditions:
- run validation_result as rest_post("https://validator.com/check", {"email": email, "phone": phone})
# PUT requests with headers
-- calculate update_result as rest_put("https://api.example.com/users/123", user_data, {"Authorization": "Bearer " + token})
+- run update_result as rest_put("https://api.example.com/users/123", user_data, {"Authorization": "Bearer " + token})
# DELETE requests
-- calculate delete_result as rest_delete("https://api.example.com/records/" + record_id)
+- run delete_result as rest_delete("https://api.example.com/records/" + record_id)
```
+> **REST error contract:** On HTTP failure (non-2xx, network error, DNS, timeout), the REST
+> functions return a structured map: `{success: false, error: true, message: ""}`.
+> Rules can branch on `response.success` to handle errors gracefully. This is intentional
+> "chain-friendly" behaviour and is the only place in the engine where a failure does not
+> raise an exception; everywhere else, errors propagate as `success=false` on the rule result.
+
### JSON Path Expressions
```yaml
@@ -1106,91 +1137,91 @@ conditions:
# String manipulation
- run trimmed as trim(" hello ")
-- calculate length as length("hello") # Also: len
-- calculate substring as substring("hello", 1, 3) # Also: substr
-- calculate contains_check as contains("hello", "ell")
-- calculate starts_check as startswith("hello", "he")
-- calculate ends_check as endswith("hello", "lo")
-- calculate replaced as replace("hello", "l", "x")
+- run name_length as length("hello") # Also: len
+- run substring as substring("hello", 1, 3) # Also: substr
+- run contains_check as contains("hello", "ell")
+- run starts_check as startswith("hello", "he")
+- run ends_check as endswith("hello", "lo")
+- run replaced as replace("hello", "l", "x")
```
### Financial Functions
```yaml
# Loan and interest calculations
-- calculate monthly_payment as calculate_loan_payment(principal, annual_rate, term_months)
-- calculate compound_interest as calculate_compound_interest(principal, rate, time)
-- calculate amortization as calculate_amortization(principal, rate, term)
-- calculate apr as calculate_apr(loan_amount, fees, monthly_payment, term)
+- run monthly_payment as calculate_loan_payment(principal, annual_rate, term_months)
+- run compound_interest as calculate_compound_interest(principal, rate, time)
+- run amortization as calculate_amortization(principal, rate, term)
+- run apr as calculate_apr(loan_amount, fees, monthly_payment, term)
# Financial ratios and metrics
-- calculate debt_ratio as debt_to_income_ratio(monthly_debt, monthly_income)
-- calculate credit_util as credit_utilization(used_credit, total_credit)
-- calculate ltv as loan_to_value(loan_amount, property_value)
-- calculate debt_ratio_alt as calculate_debt_ratio(total_debt, total_income)
-- calculate ltv_alt as calculate_ltv(loan_amount, property_value)
+- run debt_ratio as debt_to_income_ratio(monthly_debt, monthly_income)
+- run credit_util as credit_utilization(used_credit, total_credit)
+- run ltv as loan_to_value(loan_amount, property_value)
+- run debt_ratio_alt as calculate_debt_ratio(total_debt, total_income)
+- run ltv_alt as calculate_ltv(loan_amount, property_value)
# Credit and risk scoring
-- calculate credit_score as calculate_credit_score(payment_history, utilization, length, types, inquiries)
-- calculate risk_score as calculate_risk_score(credit_score, income, debt_ratio)
-- calculate payment_score as payment_history_score(payment_data)
+- run credit_score as calculate_credit_score(payment_history, utilization, length, types, inquiries)
+- run risk_score as calculate_risk_score(credit_score, income, debt_ratio)
+- run payment_score as payment_history_score(payment_data)
# Utility functions
- run formatted_amount as format_currency(1234.56)
-- calculate formatted_percent as format_percentage(0.15)
-- calculate account_num as generate_account_number()
-- calculate transaction_id as generate_transaction_id()
+- run formatted_percent as format_percentage(0.15)
+- run account_num as generate_account_number()
+- run transaction_id as generate_transaction_id()
```
### Date/Time Functions
```yaml
# Current date/time
-- calculate current_timestamp as now()
-- calculate current_date as today()
+- run current_timestamp as now()
+- run current_date as today()
# Date calculations
-- calculate date_plus as dateadd(date_value, amount, "days") # Also supports "months", "years"
-- calculate date_difference as datediff(start_date, end_date, "days")
-- calculate hour_value as time_hour(timestamp)
+- run date_plus as dateadd(date_value, amount, "days") # Also supports "months", "years"
+- run date_difference as datediff(start_date, end_date, "days")
+- run hour_value as time_hour(timestamp)
# Date validation
-- calculate is_business_day as is_business_day(date_value)
-- calculate age_check as age_meets_requirement(birth_date, min_age)
+- run is_business_day_check as is_business_day(date_value)
+- run age_check as age_meets_requirement(birth_date, min_age)
```
### List Functions
```yaml
# List operations
-- calculate list_size as size(my_list) # Also: count
+- run list_size as size(my_list) # Also: count
- run list_sum as sum(number_list)
-- run list_average as avg(number_list) # Also: average
-- calculate first_item as first(my_list)
-- calculate last_item as last(my_list)
+- run list_average as avg(number_list) # Also: average
+- run first_item as first(my_list)
+- run last_item as last(my_list)
```
### Type Conversion Functions
```yaml
# Type conversions
-- calculate as_number as tonumber("123.45") # Also: number
-- calculate as_string as tostring(123) # Also: string
-- calculate as_boolean as toboolean("true") # Also: boolean
+- run as_number as tonumber("123.45") # Also: number
+- run as_string as tostring(123) # Also: string
+- run as_boolean as toboolean("true") # Also: boolean
```
### Validation Functions
```yaml
# Financial validation
-- calculate is_valid_score as is_valid_credit_score(750)
-- calculate is_valid_ssn as is_valid_ssn("123-45-6789")
-- calculate is_valid_account as is_valid_account("1234567890")
-- calculate is_valid_routing as is_valid_routing("021000021")
+- run is_valid_score as is_valid_credit_score(750)
+- run is_valid_ssn as is_valid_ssn("123-45-6789")
+- run is_valid_account as is_valid_account("1234567890")
+- run is_valid_routing as is_valid_routing("021000021")
# General validation
-- calculate is_valid_data as is_valid(value, criteria)
-- calculate in_range_check as in_range(value, min, max)
+- run is_valid_data as is_valid(value, "email")
+- run in_range_check as in_range(value, min, max)
```
### REST API Functions
@@ -1199,10 +1230,10 @@ conditions:
# HTTP methods (all actually implemented)
- run get_response as rest_get(url)
- run post_response as rest_post(url, body)
-- calculate put_response as rest_put(url, body, headers)
-- calculate delete_response as rest_delete(url, headers)
-- calculate patch_response as rest_patch(url, body, headers)
-- calculate api_response as rest_call(method, url, body, headers)
+- run put_response as rest_put(url, body, headers)
+- run delete_response as rest_delete(url, headers)
+- run patch_response as rest_patch(url, body, headers)
+- run api_response as rest_call(method, url, body, headers)
```
### JSON Functions
@@ -1211,23 +1242,37 @@ conditions:
# JSON path operations (all actually implemented)
- run value as json_get(json_object, "path.to.property") # Also: json_path
- run exists as json_exists(json_object, "optional.property")
-- calculate size as json_size(json_object, "array_property")
-- calculate type as json_type(json_object, "property")
+- run size as json_size(json_object, "array_property")
+- run type as json_type(json_object, "property")
```
### Utility Functions
```yaml
# Distance and location
-- calculate distance as distance_between(lat1, lon1, lat2, lon2)
+- run distance as distance_between(lat1, lon1, lat2, lon2)
# Data security
-- calculate encrypted as encrypt(data, key)
-- calculate decrypted as decrypt(encrypted_data, key)
-- calculate masked as mask_data(sensitive_data, mask_pattern)
+- run encrypted as encrypt(data, key)
+- run decrypted as decrypt(encrypted_data, key)
+- run masked as mask_data(sensitive_data, mask_pattern)
# Advanced financial calculations
-- calculate payment_schedule as calculate_payment_schedule(principal, rate, term)
+- run payment_schedule as calculate_payment_schedule(principal, rate, term)
+```
+
+### Null-handling, Conditional, and Range Helpers
+
+```yaml
+# coalesce: first non-null wins (NULL-coalescing default)
+- run preferred_name as coalesce(nickname, full_name, "Anonymous")
+
+# if_else: inline ternary expression (avoids a full `if/then/else` action block)
+- run tier as if_else(creditScore at_least 750, "PRIME", "STANDARD")
+- run discount as if_else(membership equals "GOLD", 0.20, 0.05)
+
+# is_in_range: function form of the `between` operator (inclusive both ends)
+- run score_band_ok as is_in_range(score, 600, 850)
```
### Logging and Audit Functions
@@ -1239,20 +1284,59 @@ conditions:
- call send_notification with ["recipient", "message"]
```
+### Custom Functions (Extension Point)
+
+Register your own functions in a Spring `@Bean` and call them from any rule:
+
+```java
+@Configuration
+class MyRulesConfig {
+ @Bean
+ CommandLineRunner registerCustomFunctions(CustomFunctionRegistry registry) {
+ return args -> {
+ registry.register("regional_risk", a ->
+ Set.of("CA", "NY").contains(a[0]) ? 10 : 0);
+ registry.register("fraud_score", a ->
+ fraudService.score(String.valueOf(a[0])));
+ };
+ }
+}
+```
+
+```yaml
+# Then use them like any built-in function:
+when:
+ - fraud_score(applicantId) at_most MAX_FRAUD_SCORE
+then:
+ - run risk_bump as regional_risk(region)
+ - run is_clean as fraud_score(applicantId) less_than 50
+```
+
+**Resolution order:** Custom functions are checked **before** the built-in catalog -- if you
+register a function with the same name as a built-in (e.g., `max`), your function wins.
+Names are matched case-insensitively. The same registered function is reachable from both
+expression contexts (`run` / `calculate` arg / condition) and action contexts (`call`).
+
---
## Advanced Features
-### Circuit Breaker Configuration
+### Circuit Breaker -- the `circuit_breaker` Action
+
+The DSL has no top-level `circuit_breaker:` config block. Resilience and early
+termination are expressed as an **action** inside a rule's `then:` block:
+
```yaml
-circuit_breaker:
- enabled: true
- failure_threshold: 5
- timeout_duration: "30s"
- recovery_timeout: "60s"
+then:
+ - if risk_score at_least 90 then circuit_breaker "HIGH_RISK_DETECTED"
+ - set processing_status to "OK" # never executes if the previous action triggered
```
+When the action fires, the engine stops the rule cleanly. The result reports
+`success=true` with `circuitBreakerTriggered=true` and the message above; any
+already-set variables remain in the output, but no subsequent actions run.
+
### Metadata and Versioning
```yaml
@@ -1432,12 +1516,12 @@ then:
- if has_address equals true then run zip_code as json_get(customer_data, "addressInfo.zipCode")
# Validation if required
- - if requiresValidation equals true then calculate email_valid as validate_email(customer_email)
- - if requiresValidation equals true then calculate phone_valid as validate_phone(customer_phone)
+ - if requiresValidation equals true then run email_valid as validate_email(customer_email)
+ - if requiresValidation equals true then run phone_valid as validate_phone(customer_phone)
# Set processing status
- set data_enrichment_complete to true
- - calculate processing_timestamp as now()
+ - run processing_timestamp as now()
else:
- set data_enrichment_complete to false
@@ -1502,7 +1586,7 @@ rules:
- loan_to_income_ratio less_than 5.0
then:
- set risk_assessment to "LOW"
- - calculate estimated_monthly_payment as calculate_loan_payment(loanAmount, 0.05, loanTerm)
+ - run estimated_monthly_payment as calculate_loan_payment(loanAmount, 0.05, loanTerm)
- set pre_approval_status to "APPROVED"
else:
- set risk_assessment to "HIGH"
@@ -1513,11 +1597,11 @@ rules:
- pre_approval_status equals "APPROVED"
then:
- set final_status to "APPROVED"
- - calculate approval_timestamp as now()
- - call log with ["Loan approved for amount: " + loanAmount, "INFO"]
+ - run approval_timestamp as now()
+ - 'call log with ["Loan approved for amount: " + loanAmount, "INFO"]'
else:
- set final_status to "DECLINED"
- - call log with ["Loan declined - " + rejection_reason, "INFO"]
+ - 'call log with ["Loan declined - " + rejection_reason, "INFO"]'
output:
validation_stage_1: text
@@ -1535,6 +1619,7 @@ output:
### Example 4: Advanced Validation with Complex Boolean Expressions (NEW)
+
```yaml
name: "B2B Credit Scoring with Enhanced Validation"
description: "Demonstrates new validation operators in complex expressions"
@@ -1682,6 +1767,30 @@ output:
- Validation operators in conditional expressions
- Mixed validation and comparison operators in complex conditions
+### Version 26.05 - Fail-Loud Error Contract & New Primitives
+
+- **New primitives:** `coalesce(...)`, `if_else(cond, then, else)`, `is_in_range(value, low, high)`.
+- **New built-ins:** `calculate_age(birth[, asOf])`, `format_date(date[, pattern])`, `validate_email(value)`, `validate_phone(value)` (function-call form complements existing `is_email`/`is_phone` operators).
+- **Extension point:** `CustomFunctionRegistry` Spring bean lets applications register their own `RuleFunction` implementations callable from rules.
+- **Error contract:** the engine now fails loud by design rather than silently swallowing errors. See the table below.
+- **Sub-rule action parity:** sub-rules under `rules:` now accept the same map-shaped actions as the top level (e.g., YAML-collapsed `- forEach x in xs: ...`).
+
+#### Error Behavior Reference
+
+| Situation | Old (pre-26.05) | New |
+| -------------------------------------------- | ------------------------------ | -------------------------------------------------------------------- |
+| Unknown function name | `log.warn` + null | `IllegalArgumentException` -> rule reports `success=false` |
+| Non-numeric string in arithmetic | silently coerced to ZERO | `IllegalArgumentException` naming the operand |
+| Bad regex pattern in `matches` | `false` | `IllegalArgumentException` naming the pattern |
+| Missing bean property | `log.warn` + null | `IllegalArgumentException` naming class + property (maps still get null on missing key) |
+| Unknown `is_valid(value, type)` type | `false` | `IllegalArgumentException` listing supported types |
+| Unknown `dateadd`/`datediff` unit | `log.warn` + null | `IllegalArgumentException` listing supported units |
+| Action throws during execution | logged + next action runs | Rule reports `success=false` with action index + cause |
+| Condition throws during evaluation | silently flips to else branch | Rule reports `success=false` with the real cause |
+| `circuit_breaker` action triggered | (unchanged) | Rule reports `success=true` with `circuitBreakerTriggered=true` |
+| REST function HTTP failure | structured error map (unchanged) | Structured error map (unchanged -- intentional chain-friendly form) |
+| `set var to null` (e.g., `json_get` missing) | NPE caught silently | Variable stored as null; rule succeeds |
+
---
---
diff --git a/fireflyframework-rule-engine-core/pom.xml b/fireflyframework-rule-engine-core/pom.xml
index 190664d..922c35d 100644
--- a/fireflyframework-rule-engine-core/pom.xml
+++ b/fireflyframework-rule-engine-core/pom.xml
@@ -6,7 +6,7 @@
org.fireflyframeworkfireflyframework-rule-engine
- 26.05.07
+ 26.05.08fireflyframework-rule-engine-core
@@ -63,12 +63,6 @@
provided
-
-
- org.apache.commons
- commons-math3
-
-
org.threeten
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/config/DatabaseConfig.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/config/DatabaseConfig.java
index 09abda5..4704468 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/config/DatabaseConfig.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/config/DatabaseConfig.java
@@ -136,29 +136,6 @@ public ConnectionFactory connectionFactory() {
return connectionPool;
}
- // Health indicator removed - requires actuator dependency
- // TODO: Add back when actuator is available
- /*
- @Bean
- public HealthIndicator connectionPoolHealthIndicator() {
- return () -> {
- try {
- return Health.up()
- .withDetail("pool.initialSize", initialSize)
- .withDetail("pool.maxSize", maxSize)
- .withDetail("pool.minIdle", minIdle)
- .withDetail("pool.maxIdleTime", maxIdleTime.toString())
- .withDetail("pool.maxAcquireTime", maxAcquireTime.toString())
- .build();
- } catch (Exception e) {
- return Health.down()
- .withDetail("error", e.getMessage())
- .build();
- }
- };
- }
- */
-
/**
* Scheduled task to log connection pool statistics.
* Helps monitor pool performance and identify potential issues.
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/ASTVisitor.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/ASTVisitor.java
index 9a0a2fc..199f083 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/ASTVisitor.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/ASTVisitor.java
@@ -39,17 +39,13 @@ public interface ASTVisitor {
T visitVariableExpression(VariableExpression node);
T visitLiteralExpression(LiteralExpression node);
T visitFunctionCallExpression(FunctionCallExpression node);
- T visitArithmeticExpression(ArithmeticExpression node);
- T visitJsonPathExpression(JsonPathExpression node);
- T visitRestCallExpression(RestCallExpression node);
-
+
// Condition visitors
T visitComparisonCondition(ComparisonCondition node);
T visitLogicalCondition(LogicalCondition node);
T visitExpressionCondition(ExpressionCondition node);
-
+
// Action visitors
- T visitAssignmentAction(AssignmentAction node);
T visitFunctionCallAction(FunctionCallAction node);
T visitConditionalAction(ConditionalAction node);
T visitCalculateAction(CalculateAction node);
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/action/AssignmentAction.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/action/AssignmentAction.java
deleted file mode 100644
index 502688a..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/action/AssignmentAction.java
+++ /dev/null
@@ -1,79 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.action;
-
-import org.fireflyframework.rules.core.dsl.ASTVisitor;
-import org.fireflyframework.rules.core.dsl.SourceLocation;
-import org.fireflyframework.rules.core.dsl.expression.Expression;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
-
-/**
- * Represents a variable assignment action.
- * Examples: set result to "approved", assign score to calculateScore(customer)
- */
-@Data
-@EqualsAndHashCode(callSuper = true)
-public class AssignmentAction extends Action {
-
- /**
- * Name of the variable to assign to
- */
- private String variableName;
-
- /**
- * Expression to evaluate and assign to the variable
- */
- private Expression value;
-
- /**
- * Assignment operator type
- */
- private AssignmentOperator operator;
-
- public AssignmentAction(SourceLocation location, String variableName, Expression value) {
- super(location);
- this.variableName = variableName;
- this.value = value;
- this.operator = AssignmentOperator.ASSIGN;
- }
-
- public AssignmentAction(SourceLocation location, String variableName, Expression value, AssignmentOperator operator) {
- super(location);
- this.variableName = variableName;
- this.value = value;
- this.operator = operator;
- }
-
- @Override
- public T accept(ASTVisitor visitor) {
- return visitor.visitAssignmentAction(this);
- }
-
- @Override
- public boolean hasVariableReferences() {
- return value.hasVariableReferences();
- }
-
- @Override
- public String toDebugString() {
- return String.format("%s %s %s", variableName, operator.getSymbol(), value.toDebugString());
- }
-}
-
-
-
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/action/AssignmentOperator.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/action/AssignmentOperator.java
deleted file mode 100644
index 17ca124..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/action/AssignmentOperator.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.action;
-
-/**
- * Enumeration of assignment operators.
- */
-public enum AssignmentOperator {
- ASSIGN("to", "="),
- ADD_ASSIGN("add", "+="),
- SUBTRACT_ASSIGN("subtract", "-="),
- MULTIPLY_ASSIGN("multiply", "*="),
- DIVIDE_ASSIGN("divide", "/=");
-
- private final String keyword;
- private final String symbol;
-
- AssignmentOperator(String keyword, String symbol) {
- this.keyword = keyword;
- this.symbol = symbol;
- }
-
- public String getKeyword() {
- return keyword;
- }
-
- public String getSymbol() {
- return symbol;
- }
-
- public static AssignmentOperator fromKeyword(String keyword) {
- for (AssignmentOperator op : values()) {
- if (op.keyword.equals(keyword)) {
- return op;
- }
- }
- throw new IllegalArgumentException("Unknown assignment operator: " + keyword);
- }
-
- public static AssignmentOperator fromSymbol(String symbol) {
- for (AssignmentOperator op : values()) {
- if (symbol.equals(op.symbol)) {
- return op;
- }
- }
- throw new IllegalArgumentException("Unknown assignment operator symbol: " + symbol);
- }
-}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCodeGenerator.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCodeGenerator.java
index b801255..620088e 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCodeGenerator.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/compiler/PythonCodeGenerator.java
@@ -492,58 +492,6 @@ public String visitFunctionCallExpression(FunctionCallExpression node) {
}
}
- @Override
- public String visitArithmeticExpression(ArithmeticExpression node) {
- // ArithmeticExpression is a complex expression with multiple operands
- String operation = node.getOperation().getName();
- List operands = node.getOperands().stream()
- .map(operand -> operand.accept(this))
- .collect(Collectors.toList());
-
- return switch (operation.toLowerCase()) {
- case "sum" -> String.format("sum([%s])", String.join(", ", operands));
- case "average", "avg" -> String.format("statistics.mean([%s])", String.join(", ", operands));
- case "max" -> String.format("max(%s)", String.join(", ", operands));
- case "min" -> String.format("min(%s)", String.join(", ", operands));
- default -> String.format("firefly_%s(%s)", operation, String.join(", ", operands));
- };
- }
-
- @Override
- public String visitJsonPathExpression(JsonPathExpression node) {
- String sourceExpression = node.getSourceExpression().accept(this);
- String jsonPath = "\"" + node.getJsonPath() + "\"";
- return String.format("json_path_get(%s, %s)", sourceExpression, jsonPath);
- }
-
- @Override
- public String visitRestCallExpression(RestCallExpression node) {
- String method = "\"" + node.getHttpMethod() + "\"";
- String url = node.getUrlExpression().accept(this);
-
- StringBuilder restCall = new StringBuilder();
- restCall.append("rest_call(").append(method).append(", ").append(url);
-
- if (node.getBodyExpression() != null) {
- restCall.append(", ").append(node.getBodyExpression().accept(this));
- } else {
- restCall.append(", None");
- }
-
- if (node.getHeadersExpression() != null) {
- restCall.append(", ").append(node.getHeadersExpression().accept(this));
- } else {
- restCall.append(", None");
- }
-
- if (node.getTimeoutExpression() != null) {
- restCall.append(", ").append(node.getTimeoutExpression().accept(this));
- }
-
- restCall.append(")");
- return restCall.toString();
- }
-
// Condition visitors
@Override
public String visitComparisonCondition(ComparisonCondition node) {
@@ -614,24 +562,6 @@ public String visitExpressionCondition(ExpressionCondition node) {
}
// Action visitors
- @Override
- public String visitAssignmentAction(AssignmentAction node) {
- String value = node.getValue().accept(this);
- String varName = sanitizeVariableName(node.getVariableName());
-
- return switch (node.getOperator()) {
- case ASSIGN -> String.format("%s['%s'] = %s", CONTEXT_VAR, varName, value);
- case ADD_ASSIGN -> String.format("%s['%s'] = %s.get('%s', 0) + %s",
- CONTEXT_VAR, varName, CONTEXT_VAR, varName, value);
- case SUBTRACT_ASSIGN -> String.format("%s['%s'] = %s.get('%s', 0) - %s",
- CONTEXT_VAR, varName, CONTEXT_VAR, varName, value);
- case MULTIPLY_ASSIGN -> String.format("%s['%s'] = %s.get('%s', 1) * %s",
- CONTEXT_VAR, varName, CONTEXT_VAR, varName, value);
- case DIVIDE_ASSIGN -> String.format("%s['%s'] = %s.get('%s', 1) / %s",
- CONTEXT_VAR, varName, CONTEXT_VAR, varName, value);
- };
- }
-
@Override
public String visitFunctionCallAction(FunctionCallAction node) {
String functionName = mapFunctionToPython(node.getFunctionName());
@@ -942,9 +872,6 @@ private Set extractSetVariablesFromActions(List actions) {
} else if (action instanceof CalculateAction) {
CalculateAction calcAction = (CalculateAction) action;
setVariables.add(calcAction.getResultVariable());
- } else if (action instanceof AssignmentAction) {
- AssignmentAction assignAction = (AssignmentAction) action;
- setVariables.add(assignAction.getVariableName());
}
}
@@ -1111,31 +1038,6 @@ private String mapComparisonOperatorToPython(ComparisonOperator operator) {
};
}
- private String mapArithmeticOperationToPython(ArithmeticOperation operation) {
- return switch (operation) {
- // Basic arithmetic
- case ADD -> "+";
- case SUBTRACT -> "-";
- case MULTIPLY -> "*";
- case DIVIDE -> "/";
- case MODULO -> "%";
- case POWER -> "**";
-
- // Mathematical functions
- case ABS -> "abs";
- case MIN -> "min";
- case MAX -> "max";
- case ROUND -> "round";
- case FLOOR -> "math.floor";
- case CEIL -> "math.ceil";
- case SQRT -> "math.sqrt";
- case SUM -> "sum";
- case AVERAGE -> "firefly_average";
-
- default -> throw new UnsupportedOperationException("Unsupported arithmetic operation: " + operation);
- };
- }
-
private String mapFunctionToPython(String functionName) {
return switch (functionName.toLowerCase()) {
// Built-in Python functions
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/ASTRulesEvaluationEngine.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/ASTRulesEvaluationEngine.java
index 43be71b..ca4bd39 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/ASTRulesEvaluationEngine.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/ASTRulesEvaluationEngine.java
@@ -23,6 +23,7 @@
import org.fireflyframework.rules.core.dsl.condition.ExpressionCondition;
import org.fireflyframework.rules.core.dsl.condition.LogicalCondition;
import org.fireflyframework.rules.core.dsl.expression.*;
+import org.fireflyframework.rules.core.dsl.function.CustomFunctionRegistry;
import org.fireflyframework.rules.core.dsl.model.ASTRulesDSL;
import org.fireflyframework.rules.core.dsl.parser.ASTRulesDSLParser;
import org.fireflyframework.rules.core.dsl.visitor.ActionExecutor;
@@ -39,6 +40,7 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
import java.util.*;
import java.util.stream.Collectors;
@@ -55,40 +57,64 @@ public class ASTRulesEvaluationEngine {
private final ConstantService constantService;
private final RestCallService restCallService;
private final JsonPathService jsonPathService;
+ private final CustomFunctionRegistry customFunctions;
/**
- * Primary constructor for Spring dependency injection
- * RestCallService and JsonPathService are optional and will use defaults if not provided
+ * Primary constructor for Spring dependency injection.
+ *
+ * {@code restCallService}, {@code jsonPathService}, and {@code customFunctions} are
+ * optional. When absent, REST/JSON built-ins fall back to internal default implementations
+ * and no user-registered functions are available.
*/
@Autowired
public ASTRulesEvaluationEngine(ASTRulesDSLParser parser,
ConstantService constantService,
@Autowired(required = false) RestCallService restCallService,
- @Autowired(required = false) JsonPathService jsonPathService) {
+ @Autowired(required = false) JsonPathService jsonPathService,
+ @Autowired(required = false) CustomFunctionRegistry customFunctions) {
this.parser = Objects.requireNonNull(parser, "ASTRulesDSLParser cannot be null");
this.constantService = Objects.requireNonNull(constantService, "ConstantService cannot be null");
this.restCallService = restCallService != null ? restCallService : new RestCallServiceImpl();
this.jsonPathService = jsonPathService != null ? jsonPathService : new JsonPathServiceImpl();
+ this.customFunctions = customFunctions;
}
/**
- * Constructor with default REST and JSON services (for testing)
+ * Test-friendly constructor with default REST/JSON services and no custom function registry.
*/
public ASTRulesEvaluationEngine(ASTRulesDSLParser parser, ConstantService constantService) {
this.parser = Objects.requireNonNull(parser, "ASTRulesDSLParser cannot be null");
this.constantService = Objects.requireNonNull(constantService, "ConstantService cannot be null");
this.restCallService = new RestCallServiceImpl();
this.jsonPathService = new JsonPathServiceImpl();
+ this.customFunctions = null;
+ }
+
+ /**
+ * Test-friendly 4-arg constructor (parser, constantService, restCallService, jsonPathService)
+ * preserved for backward compatibility with existing tests. Delegates to the 5-arg form with
+ * a {@code null} custom function registry.
+ */
+ public ASTRulesEvaluationEngine(ASTRulesDSLParser parser,
+ ConstantService constantService,
+ RestCallService restCallService,
+ JsonPathService jsonPathService) {
+ this(parser, constantService, restCallService, jsonPathService, null);
}
/**
- * Evaluate rules against the provided input data
+ * Evaluate rules against the provided input data.
+ *
+ * The visitor-based evaluator is synchronous and may transitively block (e.g., built-in
+ * REST/JSON functions). To avoid stalling the Netty event loop, the evaluation step is
+ * scheduled on {@code Schedulers.boundedElastic()} which is designed for blocking work.
*/
public Mono evaluateRulesReactive(String rulesDefinition, Map inputData) {
long startTime = System.currentTimeMillis();
return parser.parseRulesReactive(rulesDefinition)
.flatMap(rulesDSL -> createEvaluationContextReactive(rulesDSL, inputData)
- .map(context -> evaluateRules(rulesDSL, context)))
+ .flatMap(context -> Mono.fromCallable(() -> evaluateRules(rulesDSL, context))
+ .subscribeOn(Schedulers.boundedElastic())))
.onErrorResume(error -> {
long executionTime = System.currentTimeMillis() - startTime;
JsonLogger.error(log, "Rules evaluation failed", error);
@@ -113,12 +139,14 @@ public ASTRulesEvaluationResult evaluateRules(String rulesDefinition, Map evaluateRulesReactive(ASTRulesDSL rulesDSL, Map inputData) {
long startTime = System.currentTimeMillis();
return createEvaluationContextReactive(rulesDSL, inputData)
- .map(context -> evaluateRules(rulesDSL, context))
+ .flatMap(context -> Mono.fromCallable(() -> evaluateRules(rulesDSL, context))
+ .subscribeOn(Schedulers.boundedElastic()))
.onErrorResume(error -> {
long executionTime = System.currentTimeMillis() - startTime;
JsonLogger.error(log, "Rules evaluation failed", error);
@@ -239,21 +267,25 @@ private boolean evaluateConditions(List conditions, EvaluationContext
for (int i = 0; i < conditions.size(); i++) {
Condition condition = conditions.get(i);
+ ExpressionEvaluator evaluator = new ExpressionEvaluator(context, restCallService, jsonPathService, customFunctions);
+ Object result;
try {
- ExpressionEvaluator evaluator = new ExpressionEvaluator(context, restCallService, jsonPathService);
- Object result = condition.accept(evaluator);
- boolean boolResult = toBoolean(result);
-
- JsonLogger.info(log, context.getOperationId(),
- String.format("Condition %d evaluation: %s = %s", i + 1, condition.toDebugString(), boolResult));
-
- if (!boolResult) {
- JsonLogger.info(log, context.getOperationId(), "Condition failed - short-circuiting evaluation");
- return false;
- }
- } catch (Exception e) {
- String operationId = context.getOperationId();
- JsonLogger.error(log, operationId, "Error evaluating condition: " + condition.toDebugString(), e);
+ result = condition.accept(evaluator);
+ } catch (RuntimeException e) {
+ // Propagate so the outer evaluateRules() handler reports success=false
+ // with the real cause; swallowing here would silently flip rules to the
+ // else branch and mask authoring or data bugs.
+ throw new RuleEvaluationException(
+ "Failed to evaluate condition " + (i + 1) + " (" + condition.toDebugString() + "): "
+ + e.getMessage(), e);
+ }
+
+ boolean boolResult = toBoolean(result);
+ JsonLogger.info(log, context.getOperationId(),
+ String.format("Condition %d evaluation: %s = %s", i + 1, condition.toDebugString(), boolResult));
+
+ if (!boolResult) {
+ JsonLogger.info(log, context.getOperationId(), "Condition failed - short-circuiting evaluation");
return false;
}
}
@@ -278,20 +310,23 @@ private void executeActions(List actions, EvaluationContext context) {
JsonLogger.info(log, context.getOperationId(),
String.format("Executing action %d: %s", i + 1, action.toDebugString()));
- ActionExecutor executor = new ActionExecutor(context, restCallService, jsonPathService);
+ ActionExecutor executor = new ActionExecutor(context, restCallService, jsonPathService, customFunctions);
action.accept(executor);
JsonLogger.info(log, context.getOperationId(),
String.format("Action %d completed successfully", i + 1));
} catch (org.fireflyframework.rules.core.dsl.exception.CircuitBreakerException e) {
- // Circuit breaker is a controlled stop, not an error
JsonLogger.info(log, context.getOperationId(),
"Circuit breaker triggered: " + e.getCircuitBreakerMessage() + " - stopping execution");
- // Re-throw to stop execution immediately
throw e;
- } catch (Exception e) {
- String operationId = context.getOperationId();
- JsonLogger.error(log, operationId, "Error executing action: " + action.toDebugString(), e);
+ } catch (RuntimeException e) {
+ // Fail-fast: the previous swallow-and-continue policy let later actions
+ // read variables that the failing action never set, masking the real cause.
+ // The outer evaluateRules() catch converts this into success=false with the
+ // original message preserved.
+ throw new RuleEvaluationException(
+ "Failed to execute action " + (i + 1) + " (" + action.toDebugString() + "): "
+ + e.getMessage(), e);
}
}
JsonLogger.info(log, context.getOperationId(), "All actions completed");
@@ -354,29 +389,28 @@ private boolean evaluateConditionalBlock(ASTRulesDSL.ASTConditionalBlock conditi
if (conditionalBlock == null || conditionalBlock.getIfCondition() == null) {
return false;
}
-
+
+ ExpressionEvaluator evaluator = new ExpressionEvaluator(context, restCallService, jsonPathService, customFunctions);
+ Object result;
try {
- // Evaluate the condition using AST
- ExpressionEvaluator evaluator = new ExpressionEvaluator(context, restCallService, jsonPathService);
- Object result = conditionalBlock.getIfCondition().accept(evaluator);
- boolean conditionResult = toBoolean(result);
-
- // Execute appropriate action block
- ASTRulesDSL.ASTActionBlock actionBlock = conditionResult ?
- conditionalBlock.getThenBlock() :
- conditionalBlock.getElseBlock();
-
- if (actionBlock != null) {
- executeActionBlock(actionBlock, context);
- }
-
- return conditionResult;
-
- } catch (Exception e) {
- String operationId = context.getOperationId();
- JsonLogger.error(log, operationId, "Error evaluating conditional block", e);
- return false;
+ result = conditionalBlock.getIfCondition().accept(evaluator);
+ } catch (RuntimeException e) {
+ // Propagate so the rule reports the real cause instead of silently falling
+ // through to the else branch.
+ throw new RuleEvaluationException(
+ "Failed to evaluate conditional block 'if' clause: " + e.getMessage(), e);
+ }
+ boolean conditionResult = toBoolean(result);
+
+ ASTRulesDSL.ASTActionBlock actionBlock = conditionResult ?
+ conditionalBlock.getThenBlock() :
+ conditionalBlock.getElseBlock();
+
+ if (actionBlock != null) {
+ executeActionBlock(actionBlock, context);
}
+
+ return conditionResult;
}
/**
@@ -395,7 +429,6 @@ private void executeActionBlock(ASTRulesDSL.ASTActionBlock actionBlock, Evaluati
executeActions(actionBlock.getActions(), context);
}
- // Handle nested conditional blocks - this was the TODO that's now implemented!
if (actionBlock.getNestedConditions() != null) {
evaluateConditionalBlock(actionBlock.getNestedConditions(), context);
}
@@ -666,12 +699,6 @@ public Void visitExpressionCondition(ExpressionCondition node) {
return null;
}
- @Override
- public Void visitAssignmentAction(AssignmentAction node) {
- node.getValue().accept(this);
- return null;
- }
-
@Override
public Void visitCalculateAction(CalculateAction node) {
node.getExpression().accept(this);
@@ -737,16 +764,6 @@ public Void visitFunctionCallExpression(FunctionCallExpression node) {
return null;
}
- @Override
- public Void visitArithmeticExpression(ArithmeticExpression node) {
- if (node.getOperands() != null) {
- node.getOperands().forEach(operand -> operand.accept(this));
- }
- return null;
- }
-
-
-
@Override
public Void visitArithmeticAction(ArithmeticAction node) {
if (node.getValue() != null) {
@@ -819,32 +836,5 @@ public Void visitDoWhileAction(DoWhileAction node) {
return null;
}
-
- @Override
- public Void visitJsonPathExpression(JsonPathExpression node) {
- // Visit the source expression to collect any variable references
- if (node.getSourceExpression() != null) {
- node.getSourceExpression().accept(this);
- }
- return null;
- }
-
- @Override
- public Void visitRestCallExpression(RestCallExpression node) {
- // Visit all expressions to collect any variable references
- if (node.getUrlExpression() != null) {
- node.getUrlExpression().accept(this);
- }
- if (node.getBodyExpression() != null) {
- node.getBodyExpression().accept(this);
- }
- if (node.getHeadersExpression() != null) {
- node.getHeadersExpression().accept(this);
- }
- if (node.getTimeoutExpression() != null) {
- node.getTimeoutExpression().accept(this);
- }
- return null;
- }
}
}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/RuleEvaluationException.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/RuleEvaluationException.java
new file mode 100644
index 0000000..dd6310a
--- /dev/null
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/evaluation/RuleEvaluationException.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024-2026 Firefly Software Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.fireflyframework.rules.core.dsl.evaluation;
+
+/**
+ * Raised when an action or condition fails during rule evaluation.
+ *
+ * The engine wraps the underlying failure (e.g. unknown function, type-coercion error,
+ * division by zero, missing variable) with the index of the offending action or condition
+ * and its source debug string so the outer evaluator can report a precise
+ * {@code success=false} result instead of silently flipping to the else branch.
+ */
+public class RuleEvaluationException extends RuntimeException {
+
+ public RuleEvaluationException(String message) {
+ super(message);
+ }
+
+ public RuleEvaluationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/exception/ASTException.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/exception/ASTException.java
index 38f38ec..55bc374 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/exception/ASTException.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/exception/ASTException.java
@@ -55,6 +55,10 @@ public ASTException(String message, SourceLocation location) {
public ASTException(String message) {
this(message, null, "AST_GENERIC", List.of(), null);
}
+
+ public ASTException(String message, Throwable cause) {
+ this(message, null, "AST_GENERIC", List.of(), cause);
+ }
/**
* Get a detailed error message with location and suggestions
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/ArithmeticExpression.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/ArithmeticExpression.java
deleted file mode 100644
index 591b3b0..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/ArithmeticExpression.java
+++ /dev/null
@@ -1,103 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.expression;
-
-import org.fireflyframework.rules.core.dsl.ASTVisitor;
-import org.fireflyframework.rules.core.dsl.SourceLocation;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
-
-import java.util.List;
-import java.util.stream.Collectors;
-
-/**
- * Represents an arithmetic expression with multiple operands.
- * Examples: add(a, b, c), multiply(x, y), max(values...)
- */
-@Data
-@EqualsAndHashCode(callSuper = true)
-public class ArithmeticExpression extends Expression {
-
- /**
- * The arithmetic operation to perform
- */
- private ArithmeticOperation operation;
-
- /**
- * Operands for the arithmetic operation
- */
- private List operands;
-
- public ArithmeticExpression(SourceLocation location, ArithmeticOperation operation, List operands) {
- super(location);
- this.operation = operation;
- this.operands = operands;
- }
-
- @Override
- public T accept(ASTVisitor visitor) {
- return visitor.visitArithmeticExpression(this);
- }
-
- @Override
- public ExpressionType getExpressionType() {
- return ExpressionType.NUMBER;
- }
-
- @Override
- public boolean isConstant() {
- return operands != null && operands.stream().allMatch(Expression::isConstant);
- }
-
- @Override
- public boolean hasVariableReferences() {
- return operands != null && operands.stream().anyMatch(Expression::hasVariableReferences);
- }
-
- @Override
- public String toDebugString() {
- if (operands == null || operands.isEmpty()) {
- return operation.getSymbol() + "()";
- }
-
- String args = operands.stream()
- .map(Expression::toDebugString)
- .collect(Collectors.joining(", "));
-
- return operation.getSymbol() + "(" + args + ")";
- }
-
- /**
- * Get the number of operands
- */
- public int getOperandCount() {
- return operands != null ? operands.size() : 0;
- }
-
- /**
- * Get a specific operand by index
- */
- public Expression getOperand(int index) {
- if (operands == null || index < 0 || index >= operands.size()) {
- throw new IndexOutOfBoundsException("Operand index out of bounds: " + index);
- }
- return operands.get(index);
- }
-}
-
-
-
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/ArithmeticOperation.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/ArithmeticOperation.java
deleted file mode 100644
index ec1ecb9..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/ArithmeticOperation.java
+++ /dev/null
@@ -1,89 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.expression;
-
-/**
- * Enumeration of arithmetic operations.
- */
-public enum ArithmeticOperation {
- // Basic arithmetic
- ADD("add", "+", 6),
- SUBTRACT("subtract", "-", 6),
- MULTIPLY("multiply", "*", 7),
- DIVIDE("divide", "/", 7),
- MODULO("modulo", "%", 7),
- POWER("power", "^", 8),
-
- // Mathematical functions
- ABS("abs", "abs", 9),
- MIN("min", "min", 9),
- MAX("max", "max", 9),
- ROUND("round", "round", 9),
- FLOOR("floor", "floor", 9),
- CEIL("ceil", "ceil", 9),
- SQRT("sqrt", "sqrt", 9),
- SUM("sum", "sum", 9),
- AVERAGE("average", "avg", 9);
-
- private final String name;
- private final String symbol;
- private final int precedence;
-
- ArithmeticOperation(String name, String symbol, int precedence) {
- this.name = name;
- this.symbol = symbol;
- this.precedence = precedence;
- }
-
- public String getName() {
- return name;
- }
-
- public String getSymbol() {
- return symbol;
- }
-
- public int getPrecedence() {
- return precedence;
- }
-
- public int getMinOperands() {
- return switch (this) {
- case ABS, ROUND, FLOOR, CEIL, SQRT -> 1;
- case MIN, MAX -> 2;
- case SUM, AVERAGE -> 1; // Can take 1 or more
- default -> 2; // Binary operations
- };
- }
-
- public int getMaxOperands() {
- return switch (this) {
- case ABS, ROUND, FLOOR, CEIL, SQRT -> 1;
- case SUM, AVERAGE -> Integer.MAX_VALUE; // Can take any number
- default -> 2; // Binary operations
- };
- }
-
- public static ArithmeticOperation fromSymbol(String symbol) {
- for (ArithmeticOperation op : values()) {
- if (op.symbol.equals(symbol)) {
- return op;
- }
- }
- throw new IllegalArgumentException("Unknown arithmetic operation: " + symbol);
- }
-}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/JsonPathExpression.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/JsonPathExpression.java
deleted file mode 100644
index c3779c1..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/JsonPathExpression.java
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.expression;
-
-import org.fireflyframework.rules.core.dsl.ASTVisitor;
-import org.fireflyframework.rules.core.dsl.SourceLocation;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
-
-/**
- * Represents a JSON path expression for accessing nested JSON values.
- * Examples:
- * - user.name (access name property of user object)
- * - users[0].email (access email of first user in array)
- * - response.data.items[2].price (deep nested access)
- * - todos.length (get array length)
- */
-@Data
-@EqualsAndHashCode(callSuper = true)
-public class JsonPathExpression extends Expression {
-
- /**
- * The source expression that contains the JSON data
- */
- private Expression sourceExpression;
-
- /**
- * The JSON path to access (e.g., "user.name", "items[0].price")
- */
- private String jsonPath;
-
- public JsonPathExpression(SourceLocation location, Expression sourceExpression, String jsonPath) {
- super(location);
- this.sourceExpression = sourceExpression;
- this.jsonPath = jsonPath;
- }
-
- @Override
- public T accept(ASTVisitor visitor) {
- return visitor.visitJsonPathExpression(this);
- }
-
- @Override
- public ExpressionType getExpressionType() {
- // JSON path can return any type depending on the path
- return ExpressionType.ANY;
- }
-
- @Override
- public boolean isConstant() {
- // JSON path expressions are not constant since they depend on runtime data
- return false;
- }
-
- @Override
- public boolean hasVariableReferences() {
- return sourceExpression != null && sourceExpression.hasVariableReferences();
- }
-
- @Override
- public String toDebugString() {
- return String.format("JsonPath(%s.%s)",
- sourceExpression != null ? sourceExpression.toDebugString() : "null",
- jsonPath);
- }
-}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/RestCallExpression.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/RestCallExpression.java
deleted file mode 100644
index 0873ac1..0000000
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/expression/RestCallExpression.java
+++ /dev/null
@@ -1,120 +0,0 @@
-/*
- * Copyright 2024-2026 Firefly Software Foundation
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.fireflyframework.rules.core.dsl.expression;
-
-import org.fireflyframework.rules.core.dsl.ASTVisitor;
-import org.fireflyframework.rules.core.dsl.SourceLocation;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
-
-/**
- * Represents a REST API call expression.
- * Examples:
- * - rest_get("https://api.example.com/users/123")
- * - rest_post("https://api.example.com/users", {"name": "John", "email": "john@example.com"})
- * - rest_put("https://api.example.com/users/123", userData, {"Authorization": "Bearer token"})
- */
-@Data
-@EqualsAndHashCode(callSuper = true)
-public class RestCallExpression extends Expression {
-
- /**
- * HTTP method (GET, POST, PUT, DELETE, etc.)
- */
- private String httpMethod;
-
- /**
- * URL expression for the REST endpoint
- */
- private Expression urlExpression;
-
- /**
- * Optional request body expression (for POST, PUT, etc.)
- */
- private Expression bodyExpression;
-
- /**
- * Optional headers expression (Map)
- */
- private Expression headersExpression;
-
- /**
- * Optional timeout in milliseconds
- */
- private Expression timeoutExpression;
-
- public RestCallExpression(SourceLocation location, String httpMethod, Expression urlExpression) {
- super(location);
- this.httpMethod = httpMethod.toUpperCase();
- this.urlExpression = urlExpression;
- }
-
- public RestCallExpression(SourceLocation location, String httpMethod, Expression urlExpression,
- Expression bodyExpression, Expression headersExpression, Expression timeoutExpression) {
- super(location);
- this.httpMethod = httpMethod.toUpperCase();
- this.urlExpression = urlExpression;
- this.bodyExpression = bodyExpression;
- this.headersExpression = headersExpression;
- this.timeoutExpression = timeoutExpression;
- }
-
- @Override
- public T accept(ASTVisitor visitor) {
- return visitor.visitRestCallExpression(this);
- }
-
- @Override
- public ExpressionType getExpressionType() {
- // REST calls return JSON objects/arrays
- return ExpressionType.ANY;
- }
-
- @Override
- public boolean isConstant() {
- // REST calls are never constant since they involve external API calls
- return false;
- }
-
- @Override
- public boolean hasVariableReferences() {
- return (urlExpression != null && urlExpression.hasVariableReferences()) ||
- (bodyExpression != null && bodyExpression.hasVariableReferences()) ||
- (headersExpression != null && headersExpression.hasVariableReferences()) ||
- (timeoutExpression != null && timeoutExpression.hasVariableReferences());
- }
-
- @Override
- public String toDebugString() {
- StringBuilder sb = new StringBuilder();
- sb.append("RestCall(").append(httpMethod).append(" ");
- sb.append(urlExpression != null ? urlExpression.toDebugString() : "null");
-
- if (bodyExpression != null) {
- sb.append(", body=").append(bodyExpression.toDebugString());
- }
- if (headersExpression != null) {
- sb.append(", headers=").append(headersExpression.toDebugString());
- }
- if (timeoutExpression != null) {
- sb.append(", timeout=").append(timeoutExpression.toDebugString());
- }
-
- sb.append(")");
- return sb.toString();
- }
-}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/CustomFunctionRegistry.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/CustomFunctionRegistry.java
new file mode 100644
index 0000000..337fd1d
--- /dev/null
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/CustomFunctionRegistry.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2024-2026 Firefly Software Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.fireflyframework.rules.core.dsl.function;
+
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Registry of user-defined {@link RuleFunction} implementations available to the rule DSL.
+ *
+ * The registry is consulted before the built-in function catalog, allowing applications to
+ * extend or override the engine without modifying its source.
+ *
+ *
Lookups are case-insensitive. The registry is thread-safe.
+ */
+@Component
+public class CustomFunctionRegistry {
+
+ private final Map functions = new ConcurrentHashMap<>();
+
+ /**
+ * Register a function under the given name. If a function with the same name (ignoring case)
+ * is already registered, it is replaced.
+ *
+ * @param name the function name as referenced from the DSL; must not be {@code null} or blank
+ * @param function the function implementation; must not be {@code null}
+ * @throws IllegalArgumentException if {@code name} is blank or {@code function} is {@code null}
+ */
+ public void register(String name, RuleFunction function) {
+ if (name == null || name.isBlank()) {
+ throw new IllegalArgumentException("Function name must not be blank");
+ }
+ if (function == null) {
+ throw new IllegalArgumentException("Function must not be null");
+ }
+ functions.put(name.toLowerCase(Locale.ROOT), function);
+ }
+
+ /**
+ * Remove a registered function by name. No-op if the name is not registered.
+ *
+ * @return {@code true} if the function existed and was removed
+ */
+ public boolean unregister(String name) {
+ if (name == null) return false;
+ return functions.remove(name.toLowerCase(Locale.ROOT)) != null;
+ }
+
+ /**
+ * Look up a registered function by name (case-insensitive).
+ */
+ public Optional lookup(String name) {
+ if (name == null) return Optional.empty();
+ return Optional.ofNullable(functions.get(name.toLowerCase(Locale.ROOT)));
+ }
+
+ /**
+ * Return the names of all currently registered functions (case folded to lower-case).
+ * The returned set is an immutable snapshot.
+ */
+ public Set registeredNames() {
+ return Collections.unmodifiableSet(Set.copyOf(functions.keySet()));
+ }
+
+ /**
+ * Whether a function with the given name (case-insensitive) is registered.
+ */
+ public boolean contains(String name) {
+ return lookup(name).isPresent();
+ }
+}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/RuleFunction.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/RuleFunction.java
new file mode 100644
index 0000000..6a0f99d
--- /dev/null
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/function/RuleFunction.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2024-2026 Firefly Software Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.fireflyframework.rules.core.dsl.function;
+
+/**
+ * Pluggable function callable from the rule DSL via the {@code call} action or as part of an
+ * expression (e.g., {@code calculate result as my_function(amount, rate)}).
+ *
+ * Implementations are registered with {@link CustomFunctionRegistry}. Custom functions are
+ * looked up before the built-in catalog, so a registration with the same name as a
+ * built-in (e.g., {@code "max"}) deliberately shadows the built-in. Names are matched
+ * case-insensitively.
+ *
+ *
Contract
+ *
+ *
Arguments arrive as evaluated values (literals, resolved variables, or nested
+ * function results) -- not as AST nodes.
+ *
Implementations should throw {@link IllegalArgumentException} for invalid argument
+ * counts or types; the evaluator surfaces these to the caller.
+ *
Implementations must be thread-safe; the same instance can be invoked concurrently
+ * across rule evaluations.
+ *
+ */
+@FunctionalInterface
+public interface RuleFunction {
+
+ /**
+ * Apply this function to the evaluated argument list.
+ *
+ * @param args evaluated argument values (may be empty, never {@code null})
+ * @return the function result; may be {@code null} if the function legitimately returns a null value
+ */
+ Object apply(Object[] args);
+}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/model/ASTRulesDSL.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/model/ASTRulesDSL.java
index f733b72..681d0f6 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/model/ASTRulesDSL.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/model/ASTRulesDSL.java
@@ -61,9 +61,6 @@ public class ASTRulesDSL {
// Complex conditions block
private ASTConditionalBlock conditions;
- // Circuit breaker configuration
- private ASTCircuitBreakerConfig circuitBreaker;
-
/**
* AST-based sub-rule definition
*/
@@ -208,17 +205,4 @@ public List getAllActions() {
return List.of();
}
- /**
- * AST-based circuit breaker configuration
- */
- @Data
- @Builder
- @AllArgsConstructor
- @NoArgsConstructor
- public static class ASTCircuitBreakerConfig {
- private boolean enabled;
- private int failureThreshold;
- private String timeoutDuration;
- private String recoveryTimeout;
- }
}
diff --git a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/parser/ASTRulesDSLParser.java b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/parser/ASTRulesDSLParser.java
index 5105dcd..25ddbd2 100644
--- a/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/parser/ASTRulesDSLParser.java
+++ b/fireflyframework-rule-engine-core/src/main/java/org/fireflyframework/rules/core/dsl/parser/ASTRulesDSLParser.java
@@ -33,8 +33,6 @@
import java.util.List;
import java.util.Map;
-import java.util.Objects;
-import java.util.Optional;
import java.util.stream.Collectors;
/**
@@ -110,12 +108,11 @@ public Mono parseRulesReactive(String rulesDefinition) {
}
/**
- * Parse rules definition from YAML string to AST model (synchronous)
- * Uses caching to avoid re-parsing identical YAML content.
- *
- * @deprecated Use parseRulesReactive() instead for reactive contexts
+ * Parse rules definition from YAML to AST -- synchronous convenience wrapper around
+ * {@link #parseRulesReactive(String)}. Intended for non-reactive callers (tests,
+ * code generation, validation tools); reactive callers should subscribe to the Mono
+ * directly. Internally blocks; safe to call from non-event-loop threads.
*/
- @Deprecated
public ASTRulesDSL parseRules(String rulesDefinition) {
return parseRulesReactive(rulesDefinition).block();
}
@@ -259,13 +256,6 @@ private ASTRulesDSL convertToASTModel(Map yamlMap) {
builder.conditions(conditions);
}
- // Circuit breaker configuration
- if (yamlMap.containsKey("circuit_breaker")) {
- Map circuitBreakerMap = (Map) yamlMap.get("circuit_breaker");
- ASTRulesDSL.ASTCircuitBreakerConfig circuitBreaker = convertToCircuitBreakerConfig(circuitBreakerMap);
- builder.circuitBreaker(circuitBreaker);
- }
-
return builder.build();
}
@@ -300,22 +290,18 @@ private ASTRulesDSL.ASTSubRule convertToSubRule(Map ruleMap) {
builder.whenConditions(whenConditions);
}
+ // Sub-rules use the same action-parsing path as the top-level when/then/else so
+ // YAML-collapsed map-syntax actions (e.g. `- forEach x in xs: action`) work in
+ // both contexts. Routing through parseActionList preserves Map entries instead of
+ // calling toString() on them.
if (ruleMap.containsKey("then")) {
- List thenStrings = convertToStringList(ruleMap.get("then"));
- List thenActions = thenStrings.stream()
- .map(dslParser::parseAction)
- .collect(Collectors.toList());
- builder.thenActions(thenActions);
+ builder.thenActions(parseActionList(ruleMap.get("then")));
}
-
+
if (ruleMap.containsKey("else")) {
- List elseStrings = convertToStringList(ruleMap.get("else"));
- List elseActions = elseStrings.stream()
- .map(dslParser::parseAction)
- .collect(Collectors.toList());
- builder.elseActions(elseActions);
+ builder.elseActions(parseActionList(ruleMap.get("else")));
}
-
+
// Complex syntax
if (ruleMap.containsKey("conditions")) {
Map conditionsMap = (Map) ruleMap.get("conditions");
@@ -373,7 +359,6 @@ private ASTRulesDSL.ASTActionBlock convertToActionBlock(Map acti
builder.actions(actions);
}
- // Parse nested conditions - this was the TODO that's now implemented!
if (actionMap.containsKey("conditions")) {
Map nestedConditionsMap = (Map) actionMap.get("conditions");
ASTRulesDSL.ASTConditionalBlock nestedConditions = convertToConditionalBlock(nestedConditionsMap);
@@ -384,7 +369,8 @@ private ASTRulesDSL.ASTActionBlock convertToActionBlock(Map acti
}
/**
- * Parse actions list that can contain either simple syntax strings or complex syntax maps
+ * Parse actions list that can contain either simple syntax strings or complex syntax maps.
+ * Throws ASTException on any parse failure rather than silently dropping malformed actions.
*/
@SuppressWarnings("unchecked")
private List parseActionsList(Object actionsObj) {
@@ -392,7 +378,6 @@ private List parseActionsList(Object actionsObj) {
List