Language reference

The complete CrowdControl grammar, operators, and semantics. For the normative spec (used by SDK ports), see SPEC.md.

Rule kinds

CrowdControl has three rule kinds. Every rule is a block with a name and a body.

  • forbid "name" { ... } — denies when all conditions match, unless an unless clause saves it.
  • warn "name" { ... } — same semantics as forbid, but tooling should treat it as non-blocking.
  • permit "name" { ... } — explicit allow. Emits a message but does not override a sibling forbid at the engine level (that's a higher-level adapter concern).

Rule body

Inside a rule body, you can have:

  • Optional metadata: description, owner, link
  • One or more conditions (all AND'd together)
  • Zero or more unless clauses (OR'd — any one true saves the rule)
  • An optional message with {field.path} interpolation
forbid "example" {
  description "Human-readable explanation"
  owner       "team-name"
  link        "https://runbook.example.com/..."

  # conditions (all AND'd)
  user.role == "intern"
  resource.environment == "production"

  # escape clauses (OR'd — any one saves the rule)
  unless user.groups contains "platform-oncall"
  unless has labels.emergency-approved

  message "{user.name} cannot touch {resource.name} in prod"
}

Conditions

Conditions reference fields of the input document using dotted paths.

Comparisons

user.role == "admin"
pr.approvals != 0
pr.approvals >= 2
count(plan.destroys) < 10

List membership

resource.type in ["aws_iam_role", "aws_kms_key"]
author.teams contains "security"
pr.changed_files contains "secrets.tf"

List operations

author.teams intersects ["security", "platform"]
labels is_subset ["approved", "reviewed", "urgent"]

Patterns and regex

resource.type matches "aws_iam_*"
resource.name matches_regex "^[a-z][a-z0-9_-]+$"

Field existence

has resource.change.after.acl
not has config.required_field

Quantifiers

any pr.changed_files matches "infra/*"
all pr.commit_authors in ["alice", "bob", "charlie"]

Empty list: any → false, all → true (vacuous).

Aggregates

count(plan.destroys) > 5
count(plan.creates) + count(plan.destroys) > 20

Transforms

lower(author.name) == "admin"
upper(resource.provider) == "AWS"
len(pr.title) >= 10
len(labels) >= 2

Arithmetic

count(plan.destroys) * 3 + count(plan.creates) > 20
pr.approvals * 2 >= count(plan.destroys)

Operators: +, -, *, /. Follows mathematical precedence. Division by zero evaluates to false (never panics).

Boolean operators

# not — negates a single condition
not resource.type matches "aws_*"

# or — disjunction within a single line
resource.type == "aws_iam_role" or resource.type == "aws_kms_key"

Multiple condition lines are always AND'd. or only binds within a single line.

Unless clauses

unless is CrowdControl's escape hatch. If any unless clause is true, the rule does not fire — regardless of whether the main conditions matched.

forbid "sensitive-iam" {
  resource.type matches "aws_iam_*"

  unless author.teams contains "security"
  unless labels contains "security-approved"
  unless pr.approvals >= 2

  message "IAM changes need security approval"
}

Any one of the three unless clauses being true saves the rule. Think of it as "forbid this… unless any of these exceptions apply".

Messages and interpolation

message strings support {field.path} placeholders that resolve at result time. You can also use {count(field.path)}.

message "{user.name} tried to delete {resource.address}"
message "too many destroys ({count(plan.destroys)}) — needs platform-team"

Unresolved placeholders are left literally, which makes missing fields easy to spot.

Default effects

The engine supports two modes:

  • DefaultAllow (default): documents pass unless a forbid fires.
  • DefaultDeny: documents are denied unless a permit fires.

In default-deny mode, if no permit fires and nothing already denied the document, an implicit (default-deny) result is added.

Comments

# line comment
// also a line comment

There are no block comments.