Markov includes a forward-chaining rule engine powered by Grule. Rules are defined at the top level of the workflow file and evaluated by gate steps. The engine translates Python/Jinja2-like condition syntax into Grule’s GRL format, so rule authors do not need to learn GRL directly.
Rules are defined in the top-level rules: block of the workflow file.
rules:
- name: approve_high_confidence
description: "Auto-approve when confidence is high"
salience: 10
when: "confidence > 0.9 and risk == 'low'"
action: continue
set_fact:
approved: true
approval_reason: "auto-approved: high confidence, low risk"
- name: reject_critical
description: "Reject critical severity items"
salience: 20
when: "severity == 'critical' and not override"
action: skip
set_fact:
approved: false
rejection_reason: "blocked: critical severity"
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
name |
string | Yes | – | Unique identifier for the rule. Used to reference it from gate steps. |
description |
string | No | "" |
Human-readable description. Used as the GRL rule description. |
salience |
int | No | 0 |
Priority. Higher values fire first. When multiple rules fire, the action from the highest-salience rule is used. |
when |
string | Yes | – | Condition expression in Python/Jinja2-like syntax (see Condition Syntax below). |
action |
string | No | "continue" |
What happens when this rule fires: continue, skip, or pause. |
set_fact |
map | No | – | Variables to set when the rule fires. |
file |
string | No | – | Load rules from an external YAML file instead of defining inline. |
| Action | Description |
|---|---|
continue |
Proceed with the workflow (default). |
skip |
Record a skip decision. Engine control-flow support is not yet implemented. |
pause |
Persist a gate receipt and stop the run. Resume it with explicit --var input. |
A pause action creates a paused run and a paused gate-step record. The gate receipt contains the selected action, fired rules, and facts. Inspect it before continuing:
markov status <run-id> --steps
Paused runs do not continue automatically. Resume requires at least one explicit variable override, which is applied before the paused gate is evaluated again:
markov resume <run-id> --var approved=true
Design the gate rules so that the supplied input changes the decision from pause to continue (or another intended action).
The when field uses Python/Jinja2-like syntax that gets tokenized and translated to GRL format by the translator in grltranslate.go.
| Token Type | Examples | Description |
|---|---|---|
| Identifiers | severity, count, auto_approved |
Bare words (letters, digits, underscores) |
| Strings | 'value' or "value" |
Single or double quoted |
| Numbers | 42, 3.14, -1 |
Integers and decimals, including negative |
| Comparison operators | ==, !=, <, >, <=, >= |
Standard comparisons |
| Logical operators | and, or, not |
Boolean logic |
| Parentheses | (, ) |
Grouping |
| Literals | None, true, True, false, False |
Special values |
The translator converts YAML expressions to GRL calls on the FactStore. Here is the complete mapping:
| YAML Expression | GRL Translation | Description |
|---|---|---|
severity |
Facts.IsTrue("severity") |
Bare identifier – truthiness check |
not auto_approved |
!Facts.IsTrue("auto_approved") |
Negated truthiness |
severity == 'high' |
Facts.GetStr("severity") == "high" |
String comparison |
severity != 'low' |
Facts.GetStr("severity") != "low" |
String inequality |
count > 5 |
Facts.GetNum("count") > 5 |
Numeric comparison |
count <= 100 |
Facts.GetNum("count") <= 100 |
Numeric less-than-or-equal |
confidence == None |
Facts.IsNil("confidence") |
Null check |
confidence != None |
!Facts.IsNil("confidence") |
Not-null check |
approved == true |
Facts.IsTrue("approved") |
Boolean true check |
approved == false |
!Facts.IsTrue("approved") |
Boolean false check |
approved != true |
!Facts.IsTrue("approved") |
Negated boolean true |
approved != false |
Facts.IsTrue("approved") |
Negated boolean false |
a == b (ident == ident) |
Facts.GetStr("a") == Facts.GetStr("b") |
Two-identifier string comparison |
a > b (ident > ident) |
Facts.GetNum("a") > Facts.GetNum("b") |
Two-identifier numeric comparison |
x and y |
x && y |
Logical AND |
x or y |
x \|\| y |
Logical OR |
(a or b) and c |
(a \|\| b) && c |
Grouped expression |
Key detail for identifier-vs-identifier comparisons: When comparing two identifiers, == and != use GetStr (string comparison), while <, >, <=, >= use GetNum (numeric comparison).
when: "severity == 'critical' and (count > 10 or override == true)"
when: "not auto_approved and confidence > 0.8"
when: "status != None and status != 'pending'"
The FactStore (factstore.go) wraps a map[string]any and provides typed accessor methods. These methods are callable from GRL rule expressions and are the bridge between your YAML conditions and the Grule engine.
| Method | Signature | Return | Description |
|---|---|---|---|
Get |
Get(key string) |
any |
Raw value from the map |
GetStr |
GetStr(key string) |
string |
String value. Returns "" if missing or nil. Non-strings are converted via fmt.Sprintf. |
GetNum |
GetNum(key string) |
float64 |
Numeric value. Returns 0 if missing, nil, or not a number. Parses numeric strings. Handles int, int32, int64, float32, float64. |
IsTrue |
IsTrue(key string) |
bool |
Truthiness check. Returns false for: missing key, nil, "", 0, false. Returns true for everything else. |
IsNil |
IsNil(key string) |
bool |
Returns true if the key is missing or its value is nil. |
Set |
Set(key string, val any) |
– | Set any value |
SetStr |
SetStr(key string, val string) |
– | Set a string value |
SetBool |
SetBool(key string, val bool) |
– | Set a boolean value |
SetNum |
SetNum(key string, val float64) |
– | Set a float64 value |
MarkFired |
MarkFired(name string) |
– | Mark a rule as fired (internal use) |
HasFired |
HasFired(name string) |
bool |
Check if a rule has fired (internal use) |
| Value | IsTrue returns |
|---|---|
| Missing key | false |
nil |
false |
"" (empty string) |
false |
0 (int, int64, float64) |
false |
false (bool) |
false |
| Non-empty string | true |
| Non-zero number | true |
true (bool) |
true |
| Any other type (maps, lists, etc.) | true |
The gate evaluation process (evaluateGate in gate.go) follows these steps:
Resolve rules – Look up each rule name from the gate’s rules: list in the file-level rules: definitions.
Build evaluation context – Copy the entire runtime context (runCtx), then template-render and merge the gate’s facts: values on top.
Create FactStore – Initialize a FactStore from the evaluation context map.
Compile rules to GRL – Each YAML rule is translated to GRL format via the tokenizer/translator. The compiled GRL includes the condition, set_fact assignments, a MarkFired call, a Changed call, and a Retract call (each rule fires at most once).
Load into Grule – GRL source is loaded into a Grule knowledge base (name: "gate", version: "1.0.0").
Execute – The Grule engine runs with MaxCycle=100 (forward chaining, up to 100 rule evaluation cycles).
Collect results – Iterate over rules to find which ones fired. The action from the highest-salience fired rule becomes the gate’s action.
Merge facts – set_fact values from fired rules and gate-level facts: are merged back into runCtx.
- name: quality_gate
type: gate
rules:
- rule_approve
- rule_reject
facts:
score: "{{ analysis.score }}"
severity: "{{ ticket.severity }}"
| Field | Type | Required | Description |
|---|---|---|---|
rules |
list of strings | Yes | Names of rules to evaluate (must exist in top-level rules:) |
facts |
map | No | Additional variables to set in the evaluation context. Values are template-rendered against runCtx. |
Rules see these variables during evaluation:
vars:)--var overridesset_fact stepsfacts: (rendered and merged last, so they can override anything above)Rules do not see step register outputs unless they are explicitly mapped through facts:.
# Without facts mapping, the gate cannot see api_result:
- name: check_api
type: http_request
register: api_result
params:
url: "https://api.example.com/status"
# Map the value you need into the gate's facts:
- name: api_gate
type: gate
rules: [rule_api_healthy]
facts:
api_status: "{{ api_result.status_code }}"
When a rule fires, its set_fact values are compiled into GRL then block assignments. The compilation maps Go types to FactStore setter methods:
| Go Type | GRL Call | Example |
|---|---|---|
bool |
Facts.SetBool("key", true) |
approved: true |
string |
Facts.SetStr("key", "value") |
reason: "auto-approved" |
int |
Facts.SetNum("key", N.0) |
retries: 3 compiles to SetNum("retries", 3.0) |
float64 |
Facts.SetNum("key", N) |
threshold: 0.85 |
| Other | Facts.SetStr("key", fmt.Sprintf("%v", val)) |
Fallback string conversion |
After execution, the engine reads back the set_fact values from the FactStore and merges them into the runtime context.
Rules can be loaded from external YAML files using the file field instead of inline definition.
rules:
- file: "rules/common-gates.yaml"
- name: inline_rule
when: "status == 'ready'"
action: continue
The external file must contain a YAML document with a top-level rules: key:
# rules/common-gates.yaml
rules:
- name: require_approval
description: "Block unapproved items"
salience: 5
when: "approved != true"
action: skip
set_fact:
gate_status: "blocked"
- name: pass_approved
description: "Allow approved items through"
salience: 10
when: "approved == true"
action: continue
set_fact:
gate_status: "passed"
Rules from external files are expanded inline during parsing. They behave identically to rules defined directly in the workflow file.
This example shows a full workflow with two rules at different priorities, a set_fact step to prepare data, and a gate that evaluates the rules.
entrypoint: review_pipeline
namespace: default
forks: 5
vars:
min_score: 70
rules:
- name: rule_approve
description: "Approve if score meets threshold and no blockers"
salience: 10
when: "score >= 70 and blocker_count == 0"
action: continue
set_fact:
decision: "approved"
approved: true
- name: rule_reject
description: "Reject critical severity regardless of score"
salience: 20
when: "severity == 'critical'"
action: skip
set_fact:
decision: "rejected"
approved: false
rejection_reason: "critical severity items require manual review"
workflows:
- name: review_pipeline
vars:
score:
severity:
blocker_count: 0
steps:
- name: prepare_facts
type: set_fact
vars:
score: "{{ score }}"
severity: "{{ severity }}"
blocker_count: "{{ blocker_count }}"
- name: quality_gate
type: gate
rules:
- rule_approve
- rule_reject
facts:
score: "{{ score }}"
severity: "{{ severity }}"
blocker_count: "{{ blocker_count }}"
- name: report_decision
type: shell_exec
params:
command: "echo 'Decision: {{ decision }}'"
Execution scenarios:
--var score=85 --var severity=low – Both rules evaluate. rule_approve fires (score >= 70, blockers = 0). rule_reject does not fire (severity is not critical). Gate action: continue. Decision: approved.
--var score=85 --var severity=critical – Both rules evaluate. Both fire. rule_reject has salience 20 vs salience 10, so its action wins. Gate action: skip. Decision: rejected.
--var score=50 --var severity=low – Neither rule fires (score < 70, severity not critical). Gate action: continue (default). No decision facts are set.
Gate facts can resolve named Jev decisions before Grule executes. Rules consume the resulting ordinary facts; reevaluation performs no API calls. See Jev decisions.