Rules
Runtime rules let Loom run or skip a workflow or individual jobs.
For compile-time filtering, use selection. Loom evaluates
workflow.rules[].if once for the run and rules[].if once for each final graph job, including every integer instance, 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 and executor. CLI/MCP expose the public workflow-rule outcome; state records retain the run and job results.
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 |
| In-memory executor decision evidence | Experimental; emitted |
| CLI/MCP outcome presentation | Experimental; consistent |
| Regex and boolean composition | Experimental; v2 only |
Job and default rules sequences | Experimental; enforced |
| In-memory per-job 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 stops before provider setup, secret resolution, provider execution, or provider-managed services. The recorded workflow or job state reflects the outcome; the public workflow-rule result 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 produces declared-rule decisions once as loom.workflow-rule-decision.v1 evidence in
its in-memory result. 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 have no explicit rule decision;
an explicitly empty contract produces allow with reason no_rules.
For each job with declared rules, the in-memory executor node result includes 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.
The two-file run recording retains resulting states and diagnostics, rather than a separate
structured decision-evidence document.
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 workflow state records the public run outcome in state.jsonl.
MCP top-level status and exit code remain the automation interface. Current runs
do not persist a receipt or return receipt_json.
See the runtime logs contract for recorded lifecycle fields.
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.
docs-lint:
stage: ci
target: linux
script:
- 'if [ "$CI_COMMIT_BRANCH" = "main" ]; then pnpm nx run loom-platform:docs-mdlint; else echo "Skipping: not on main"; fi'
Related references
Compile selection
Native root/job selection.rules filters the graph during compilation; see the
exact authoring shape. The shared
ordinary scalar evaluator also serves GitLab rules:if; runtime rules described
above retain their separate versioned contract.
Expressions accept raw single/double quoted strings, $NAME (ASCII word names,
including leading digits), null, true, false, unary !, parentheses and
==, !=, =~, !~, &&, ||. Unary negation binds most tightly, followed by
comparisons, AND, then OR; binary operators associate left. Grouped and reversed
operands are allowed. Backslashes do not escape the same quote in string tokens. Actual LF inside a
quoted token is invalid; raw backslash-n stays literal.
Every branch must parse, including branches skipped during evaluation; at most
99 lexer tokens are accepted.
Missing ordinary names are null, distinct from present-empty strings. Boolean
values compare to strings by their true/false spelling. Final results and
unary negation use presence: empty or Unicode whitespace-only strings are blank.
AND/OR instead return operands using Ruby truthiness, where only null and false
are false. Thus $EMPTY && $PRESENT returns the present value, while
$EMPTY || $PRESENT returns empty and is finally false.
Literal RE2 /pattern/flags accepts flags i, s, m, U, unescapes slash and
rejects empty or one-character bodies and invalid patterns. Quoted/variable-held
forms also allow /a/ and //. Held slash syntax uses the first syntactically matching line, including in a
value ending in LF or surrounded by other lines. Pattern contents never expand
nested variables.
Invalid held regexes/flags use literal substring fallback: "23" =~ "1234" is
true because the left string occurs in the right string. If that first pattern
line has invalid RE2, fallback uses the original whole value and does not retry
a later line. Null/false RHS does not
match. Invalid literal syntax fails compilation. Invalid operand types or unavailable
opaque object coercion produce an explicit evaluation diagnostic.
Native lookup resolves ordinary scalar aliases using composed variables and
explicit caller values. Root lookup
cannot see job overrides; evaluated unavailable runtime bindings fail with a
source diagnostic. GitLab if lookup reads raw winning strings with presence;
its environment/cache/changes consumers keep their separate expansion phases.
The paired maintained corpus is .loom/conformance/compile-selection/.