Rules
Rules let Loom run or skip a workflow or individual jobs. Loom evaluates
workflow.rules[].if once for the run and rules[].if once for each declared job, before provider
setup, secret resolution, or job execution. The workflow file still uses version: v1.
Non-empty rules and their recorded decision evidence are Experimental. Host-provider tests cover allow, deny, invalid-syntax, and missing-input outcomes across the compiler, executor, receipts, and observer events.
Current status
| Capability | Status |
|---|---|
workflow omitted | Enforced |
Empty workflow.rules sequence | Enforced |
Non-empty workflow.rules entries | Experimental; v1 and v2 |
| Versioned Graph IR transport | Defined |
| Executor preflight evaluation | Experimental; enforced |
| Durable receipt and observer evidence | Experimental; emitted |
| CLI/MCP outcome presentation | Experimental; consistent |
| Regex and boolean composition | Experimental; v2 only |
Job and default rules sequences | Experimental; enforced |
| Per-job receipt decision evidence | Experimental; emitted |
Syntax
workflow:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
workflowis a mapping that only allowsrules.workflow.rulesis a YAML sequence.- Each rule is a mapping with exactly one non-empty string field,
if. - Other keys under
workflowor a rule entry are schema errors.
Jobs and default accept the same ordered, single-field rule shape:
variables:
DEPLOY_ENV: staging
default:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
deploy:
stage: release
target: linux
script: [./deploy]
variables:
DEPLOY_ENV: production
rules:
- if: '$DEPLOY_ENV == "production" && $CI_COMMIT_BRANCH == "main"'
A job's rules sequence replaces template or default rules as a whole. An explicit rules: []
clears inherited rules and allows the job. Omission keeps the inherited or default sequence.
Run validation with:
loom check
Runtime behavior
- An omitted rule contract or an explicitly empty rule list allows execution.
- Loom validates every expression, then evaluates the rules in declaration order. The first matching rule allows execution; if no rule matches, Loom returns a typed skipped-by-rule outcome.
- Invalid syntax, a missing variable, or a non-string input fails closed.
- If no rule matches or evaluation fails, Loom records the decision and stops before run/job lifecycle events, provider setup, secret resolution, provider execution, or provider-managed services. The decision record excludes expressions and input values, as detailed below.
- Workflow rules receive only Loom's resolved pipeline-scoped predefined variables. Job rules receive the job's declared variable context and reserved job metadata described below. Neither rule scope reads ambient environment state or step variables.
The executor records declared-rule decisions once as loom.workflow-rule-decision.v1 evidence in the
run-local receipt and observer streams. Evidence contains only the outcome, a stable reason, a zero-based
rule index (or -1), and an optional diagnostic code. It never contains the expression, literal, variable
name or value, or evaluator error text. Graphs that omit workflow_rules retain the legacy output shape;
an explicitly empty contract records allow with reason no_rules.
For each job with declared rules, the receipt's node entry records JobRuleDecision. It contains the
allow, deny, or evaluation-error outcome, the decisive or failing zero-based rule index, and a stable
diagnostic code when evaluation fails. Expressions and input values are excluded. A denied or invalid
job does not start provider work and blocks jobs that require it; independent branches continue.
Job rule context
Job rules use the existing loom.job-rule-context.v1 context. Declared variables resolve in this
order:
- job variables;
default.variables;- workflow root
variables.
Loom adds reserved LOOM_* metadata for the current job after declared variables are merged.
Declarations cannot replace a reserved name. A missing variable returns WF_RULE_INPUT_MISSING.
Declared secrets are represented only by a private non-value marker during rule evaluation, so
comparing one to a string or boolean returns WF_RULE_INPUT_COMPARISON_TYPE without resolving or
reading the secret. Other context collisions and type errors also fail that job closed.
CLI and MCP outcome contract
The CLI and MCP adapters derive their public outcome from
executor_receipt.WorkflowRuleDecision. They do not parse expressions or evaluate rules again.
| Recorded decision | Public rule outcome | CLI result | MCP top-level result |
|---|---|---|---|
allow | allow | Value-safe allow message; normal run exit behavior | Preserves the run's status and exit_code |
deny | skip | Value-safe no-match message; exit 0 | status: "skipped", exit_code: 0 |
evaluation_error | failure | Value-safe diagnostic code and remediation; exit 1 | status: "failed", exit_code: 1 |
An allowed workflow can still fail later during normal execution. After the run completes, the CLI writes the value-safe allow message before returning the original run error; MCP preserves the completed run's status and exit code.
The loom_run_local MCP result adds a value-safe message and workflow_rule object. The nested
object contains the public outcome, stable reason, zero-based rule_index (or -1), optional
diagnostic_code, and the same message. Expressions, literals, input names and values, and evaluator
error text are never included.
The adapter leaves the receipt on disk unchanged. When a run declares rules, MCP returns a filtered
copy in receipt_json: it removes graph_ir.workflow_rules and replaces rule-skip or evaluation-error
text with the public message, which excludes expressions and inputs.
The on-disk receipt can retain status: "failure", exit_code: 1, and raw execution details even when
the public result is a skip. Read executor_receipt.WorkflowRuleDecision for the recorded rule decision;
use the CLI exit code or MCP top-level fields to control automation.
See the receipts contract and runtime logs contract for the durable field mappings.
Versioned Graph IR contract
When the resolved workflow explicitly declares workflow.rules, the planner carries an ordered contract:
{
"schema": "loom.graph-ir.v1",
"workflow_rules": {
"version": "loom.workflow-rules.v1",
"rules": [{ "if": "$CI_COMMIT_BRANCH == \"main\"" }]
},
"stages": ["test"],
"nodes": [],
"edges": []
}
The workflow_rules object requires both an exact version and an array-valued rules field. Each
v1 rule has exactly one non-empty string field, if. Rule order and expression text are preserved.
Missing, null, malformed, and unknown/future contracts fail closed; Loom does not attempt a
best-effort interpretation.
Graphs produced before this contract may omit workflow_rules. That legacy shape remains valid and
means no workflow-rule declaration. An explicitly declared empty rule sequence emits the versioned
object, so omission and declaration remain distinguishable.
Each resolved job that declares rules, directly or through inheritance, carries the same ordered
v2 payload under its Graph IR node:
{
"id": "test",
"rules": {
"version": "loom.workflow-rules.v2",
"rules": [{ "if": "$RUN_TESTS == \"true\"" }]
}
}
Job rule order and source text are preserved. Omitted rules omit the node field; explicit [] emits
an empty v2 payload so clearing remains distinct from omission.
Contract versions
Workflow rules use the lowest rule-contract version that represents the complete declared list. Empty and
narrow-only lists continue to emit loom.workflow-rules.v1; a list containing any valid regex,
composition, or grouping expression emits loom.workflow-rules.v2. Existing v1 Graph IR therefore
keeps its exact meaning and byte shape. V2 is a strict semantic superset of v1, while unknown future
versions still fail closed without fallback. Job rules always use v2 because job contexts support
variable-to-variable comparisons and the complete grammar.
Narrow v1 inventory
The code-level contract registry is the authority for supported versions and this inventory:
| Kind | Narrow v1 | Result |
|---|---|---|
| Predicate field | if | Boolean decision |
| Operand kinds | Variable reference, string literal | String value |
| Truthiness | $VARIABLE | Boolean |
| Equality | $VARIABLE == "literal" | Boolean |
| Inequality | $VARIABLE != "literal" | Boolean |
Regex matching, &&, ||, and grouping remain invalid in v1.
Composed v2 grammar
V2 retains every v1 leaf and adds this complete grammar:
expression = or-expression
or-expression = and-expression { hspace "||" hspace and-expression }
and-expression = primary { hspace "&&" hspace primary }
primary = predicate | "(" hspace expression hspace ")"
predicate = variable [ hspace (("==" | "!=") hspace operand | "=~" hspace string) ]
operand = variable | string
variable = "$" ("A"…"Z" | "_") { "A"…"Z" | "0"…"9" | "_" }
string = JSON double-quoted string
hspace = { " " | tab }
&& binds tighter than ||; repeated operators associate left. Parentheses override precedence.
Evaluation visits operands from left to right. A false left operand skips the right side of &&; a true
left operand skips the right side of ||. The outer rule list retains its existing first-match behavior.
Loom parses every expression and compiles every regex before reading any variable, so malformed syntax
or patterns cannot be hidden behind short-circuiting. Missing, wrong-type, or oversized regex inputs on
an unvisited branch are not accessed.
String operands use JSON escaping after YAML decoding. For example, the YAML scalar
'$REF =~ "^release/v\\d+$"' gives RE2 the pattern ^release/v\d+$. Slash has no delimiter meaning,
and V2 does not accept slash-delimited regex, !~, or unary !. Equality and inequality accept a
variable on the right and require both resolved operands to have compatible string or boolean types.
Regex engine and resource limits
=~ uses Go's standard-library regexp engine: RE2 syntax, UTF-8-aware matching, leftmost-first
results, and linear-time execution without backtracking. Matching searches anywhere in the input;
write ^ and $ when the whole string must match. Backreferences and lookaround are unsupported.
| V2 resource | Limit |
|---|---|
| Expression source after YAML decode | 4,096 bytes |
| Parenthesis nesting | 32 levels |
| Parsed leaves and boolean operators | 256 nodes |
| Decoded regex pattern | 1,024 bytes |
| Accessed input to a regex predicate | 65,536 bytes |
Invalid RE2 patterns return WF_RULE_REGEX_INVALID; exceeding a limit returns
WF_RULE_RESOURCE_LIMIT. These diagnostics are stable and do not include expression or pattern text.
For workflow rules, parser limits apply only to expressions requiring V2 syntax; complete V1
expressions keep V1 parsing and semantics inside V2 lists. Job rule validation applies the v2 source,
group, node, and pattern limits to every expression.
The evaluator returns a typed decision using only the variables the executor passes to it. The executor supplies resolved pipeline variables for workflow rules and the explicit job context for job rules before starting provider work.
Unsupported conditions
For unsupported operations such as negative regex matching, unary negation, or values outside the declared rule context, keep the condition in a job script or the surrounding CI system.
check:
stage: ci
target: linux
script:
- 'if [ "$CI_COMMIT_BRANCH" = "main" ]; then pnpm run check; else echo "Skipping: not on main"; fi'