Skip to main content

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.

Experimental

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

CapabilityStatus
workflow omittedEnforced
Empty workflow.rules sequenceEnforced
Non-empty workflow.rules entriesExperimental; v1 and v2
Versioned Graph IR transportDefined
Executor preflight evaluationExperimental; enforced
Durable receipt and observer evidenceExperimental; emitted
CLI/MCP outcome presentationExperimental; consistent
Regex and boolean compositionExperimental; v2 only
Job and default rules sequencesExperimental; enforced
Per-job receipt decision evidenceExperimental; emitted

Syntax

workflow:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
  • workflow is a mapping that only allows rules.
  • workflow.rules is a YAML sequence.
  • Each rule is a mapping with exactly one non-empty string field, if.
  • Other keys under workflow or 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:

  1. job variables;
  2. default.variables;
  3. 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 decisionPublic rule outcomeCLI resultMCP top-level result
allowallowValue-safe allow message; normal run exit behaviorPreserves the run's status and exit_code
denyskipValue-safe no-match message; exit 0status: "skipped", exit_code: 0
evaluation_errorfailureValue-safe diagnostic code and remediation; exit 1status: "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:

KindNarrow v1Result
Predicate fieldifBoolean decision
Operand kindsVariable reference, string literalString value
Truthiness$VARIABLEBoolean
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 resourceLimit
Expression source after YAML decode4,096 bytes
Parenthesis nesting32 levels
Parsed leaves and boolean operators256 nodes
Decoded regex pattern1,024 bytes
Accessed input to a regex predicate65,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'