Reference

This page describes everything Ergo accepts and everything it returns, the same for both engines. If you haven’t used Ergo before, start with the README. How to run Ergo is on the page of each engine: Ergo with OPA for ergo.rego, and Ergo CLI for the ergo command.

Policies

A policy is an object with two sections and the version of the spec it’s written for, and it can describe itself too. subjects says what the policy checks and where to find it in the input, and requirements says what must be true of it. Each section maps names to definitions. A policy is plain data, so it usually lives in a YAML or JSON file with top-level ergo_spec, subjects and requirements keys:

ergo_spec: v0.6
subjects:
  deployment: { ... }
  commit: { ... }
requirements:
  approved_deploy: { subject: deployment, ... }
  signed_commits: { subject: commit, ... }

An engine takes the policy, the input and the params, and reads only those, so the same arguments always give the same report. How to hand them over is on the page of each engine.

ergo_spec is the version of the spec the policy is written for, and this copy of Ergo follows v0.6. A policy without it, or with null, a number like 0.1, or another version, is written wrong, so every requirement’s $well_formed row fails and lists it with the policy’s other problems, as an input named policy ergo_spec. Its value says what’s wrong: missing, not a string, not a version V0.1 for a string that isn’t shaped like a version, or unsupported version v0.1, expected v0.6 for one this copy of Ergo doesn’t follow. When the policy is in a file, pass ergo_spec from that file, like data.ergo_spec above, and don’t write it in the Rego that hands the policy over: a fixed value there would say the file follows v0.6 whatever it was written for. A policy written in Rego says it in the policy itself, like {"ergo_spec": "v0.6", "subjects": ...}.

Because each name is an object key, two requirements can’t share a name, and every row in the report points back to exactly one requirement.

A policy with no requirements is never compliant: it doesn’t check anything, so it can’t vouch for anything either. The same goes for a policy that isn’t an object, or whose requirements isn’t one. Its report then has a requirement of Ergo’s own, $policy, with one $well_formed row that fails and says what is wrong, so the violations don’t come back empty. For the policy {"ergo_spec": "v0.6"} and the input {}, the row is:

{
  "cause": "value",
  "check": "$well_formed",
  "inputs": [
    {
      "locations": [],
      "name": "requirements",
      "value": [
        "missing"
      ]
    }
  ],
  "message": "requirements: missing",
  "passed": false,
  "requirement": "$policy",
  "subject": {
    "id": "$policy",
    "type": "$policy"
  }
}

requirements says missing, not an object or empty, and a wrong ergo_spec, description or meta is listed next to it, as policy ergo_spec. A requirement can’t be called $policy, or anything else that starts with $: it is written wrong, with requirement name holding a name can't start with $ in its $well_formed row. Ergo leaves out any other section of the policy, so it changes nothing in the report.

Describing a policy

A policy can have a description and a meta of its own, next to its sections. They work like a requirement’s: description says what the policy asks for, meta keeps anything else, like the id of the control the policy checks, and Ergo copies both to the top of the report and never reads them. This is where a control with several requirements keeps its text:

ergo_spec: v0.6
description: Production changes are reviewed and approved
meta: {control: DEV-0401, title: Security testing and code review}
subjects:
  deployment:
    from: deployments whose environment is "prod"
    identified_by: id
requirements:
  approved:
    subject: deployment
    checks:
      approved: approved_by is a non-empty string
  reviewed:
    subject: deployment
    checks:
      reviewed: reviewer is a non-empty string

With this input:

{"deployments": [{"id": "d-1", "environment": "prod", "approved_by": "alice", "reviewer": "bob"}]}

the report starts with:

{
  "compliant": true,
  "description": "Production changes are reviewed and approved",
  "engine": {
    "name": "ergo-rego",
    "version": "0.6.0"
  },
  "ergo": "v0.6",
  "meta": {
    "control": "DEV-0401",
    "title": "Security testing and code review"
  },
  ...
}

null counts as not written, so the defaults above give "" and {}. A description that isn’t a string, or a meta that isn’t an object or holds something JSON can’t, belongs to every requirement, so every requirement’s $well_formed row fails and lists it last, like {"name": "policy meta", "value": ["not an object"], "locations": []}. The report then shows "" or {} in its place.

Params

Params are the values that change between runs, like the regions a policy allows. They aren’t in the policy or the input, and an engine doesn’t look for them anywhere else, so how they are passed in is on the page of each engine.

A sentence reads them with $params, like region is in $params.regions. Params aren’t part of the input, so $input.params doesn’t reach them, and $params doesn’t read the input. With params of {}, a read of a field in them, like $params.regions, fails as absent, as any missing field does. With no params, null, it fails as absent too, and that includes exists, which otherwise fails a missing field as value, and does not exist, which otherwise passes. So a condition like that on a param fails the requirement instead of deciding the scope. Params that are anything else, like a string or a list, can only be a mistake, so they fail every check and every $min_subjects row as unusable, even in a policy that reads no params. A check whose path into them stops there says what they are, like $params is 3, which isn't an object for $params.a is 1, and so does the $min_subjects message of a subject that isn’t a child subject when its from line reads them with named by or named by each of, like things named by each of $params.ks, which then makes no subjects. A child subject’s from line, like t.m named by $params.env, and a whose in a from line, like things whose id is $params.x, don’t name them. A check that reads them whole, like x is in $params, says what it says with any params. $min_subjects also says why in its inputs: {"name": "$params", "value": ["not an object"], "locations": []}. Ergo has no defaults, so a policy run without its params fails instead of checking something nobody configured.

Subjects

A subject is the kind of thing a requirement checks, like a deployment or a pull request, and says where to find them in the input:

deployment:
  description: A deployment to production
  from: deployments whose environment is "prod"
  identified_by: id
FieldMeaningDefault
descriptionWhat the subject is, in words anyone can read. The report copies it into its subjects.""
fromWhere the subjects are in the input, and which of them are in scope, written as a from line.the input field named like the subject
identified_byThe path, inside one subject, to the value that identifies it.the whole subject
hasNames for paths inside the subject, so a check can write a short alias where a long path would go. See Parts.none

A from or identified_by that isn’t a string, and the fields applies_to and of, are written wrong.

The subject’s name is what the report calls each one, in every row, like "subject": {"type": "deployment", "id": "d-2"}, and in the descriptions of the checks Ergo adds, like The in-scope deployment count is at least 1. So name it the way a reader would, like deployment or pull request.

A requirement names its subject with subject. Several requirements can name the same subject, and each one checks it on its own, with its own rows. A subject that no requirement names isn’t read, so it changes nothing in the report.

A few details:

  • If from leads to a list, each item is a subject. If it leads to a single object, that object is the only subject. If it leads nowhere, to null, or to anything else, like a string or a number, there are no subjects and $min_subjects fails with cause absent, null or unusable, even with min_subjects: 0, because that’s what a typo in from looks like. A path through something that can’t hold the next key, like "build": "b1" for build.items, gives unusable, and a missing or null value along the path gives absent, as they do in a check. An empty list is different: it was read fine, so it fails $min_subjects as value, or passes with min_subjects: 0.
  • The input has to be an object. A null input fails $min_subjects as null when from is empty or left out, and as absent when it has a step. Any other input that isn’t an object, a list included, fails it as unusable.
  • If identified_by doesn’t lead anywhere, the subject’s id is null. Its rows are still there. A subject whose from uses named by each of takes its key as its id, so it can’t have an identified_by.
  • No two subjects can share an id, or their rows could be identical and the report couldn’t say which one failed. If two do, $unique_ids fails and so does the requirement. Every subject counts, even one that its conditions leave out, and null is an id like any other, so two subjects without one clash. A requirement on a child subject checks the ids of its parent and grandparent in the same way, on $unique_ancestor_ids rows.
  • An item of the list that isn’t an object, like a string or a null, is still a subject. Its id is the item itself, and its checks fail with cause not_an_object.
  • Without from, a subject reads the input field named like it, so artifact reads artifact. A subject with nothing under it, artifact: alone in YAML, does the same. To check the whole input as one subject, write from: $input. When the input is a list, from: $input and from: every $input make each item a subject, as from: things does on a list called things, with locations like /0/x. When a walk from the input breaks in one item, like commits being 5 in the second item for from: every $input.commits, failed_items names the place $input[1].commits. When the walk breaks on the input itself, like an object or null for from: every $input, the place is named $input. The expressions of $min_subjects and $unique_ids name a position on the input from $input too, like count(matching(item 1 of $input)) >= 1 for from: $input[0], and write a from that starts with named by as it is, like count(matching(every $input named by $params.k)) >= 1. A position, like from: $input[0] or from: first of $input, reads the item at it, and ends absent when the list has none. from: every $input[0] makes each item of that item a subject. Without identified_by, each row repeats the whole subject as its id, so two identical subjects clash.
  • A subject that’s written wrong fails $well_formed on every requirement that names it, so none of them is met. That’s a subject that isn’t an object, or that has a field that isn’t in the table above, like subject_type, applies_to or of, or whose from or identified_by isn’t a string or can’t be read, or whose description isn’t a string. The $well_formed row names each field with its subject, like {"name": "subjects.deployment.from", "value": ["doesn't parse"], "locations": []} or {"name": "subjects.deployment.from", "value": ["not a string"], "locations": []} for a list. Each of those requirements gets only its $well_formed row, as for a requirement that’s written wrong, and a from that isn’t a string or can’t be read is shown as <invalid from> in expressions.

From lines

A from line is a path, and whose or that with conditions joined by and keeps the subjects they hold for. Each condition starts with its own whose or that, so a second one is and whose or and that, and and followed by a bare condition is written wrong. Paths and conditions are written as in checks. identified_by is a path written the same way:

ergo_spec: v0.6
subjects:
  emergency change:
    from: deployments whose environment is "prod" and whose change_type is "emergency"
    identified_by: id
  artifact:
    identified_by: fingerprint
requirements:
  approved:
    subject: emergency change
    checks:
      approved: approved_by is a non-empty string
  signed:
    subject: artifact
    checks:
      signed: signed is true

With this input:

{"deployments": [
  {"id": "d-1", "environment": "prod", "change_type": "emergency", "approved_by": "bob"},
  {"id": "d-2", "environment": "prod", "change_type": "standard"},
  {"id": "d-3", "environment": "prod"}
],
 "artifact": {"fingerprint": "sha256:abc", "signed": true}}

the $applies rows keep d-1 and leave d-2 out:

{"check": "$applies", "subject": {"id": "d-1", "type": "emergency change"}, "passed": true, "cause": "satisfied",
 "inputs": [{"name": "change_type", "value": "emergency", "locations": ["/deployments/0/change_type"]}, {"name": "environment", "value": "prod", "locations": ["/deployments/0/environment"]}], "failed_assertions": []}
{"check": "$applies", "subject": {"id": "d-2", "type": "emergency change"}, "passed": false, "cause": "value", "message": "change_type is \"standard\"",
 "inputs": [{"name": "change_type", "value": "standard", "locations": ["/deployments/1/change_type"]}, {"name": "environment", "value": "prod", "locations": ["/deployments/1/environment"]}],
 "failed_assertions": [{"assertion": "change_type is \"emergency\"", "cause": "value"}]}
{"check": "$applies", "subject": {"id": "d-3", "type": "emergency change"}, "passed": false, "cause": "absent", "message": "change_type is missing",
 "inputs": [{"name": "change_type", "value": null, "locations": ["/deployments/2"]}, {"name": "environment", "value": "prod", "locations": ["/deployments/2/environment"]}],
 "failed_assertions": [{"assertion": "change_type is \"emergency\"", "cause": "absent"}]}

and the $applies entry’s expression is the conditions as written, environment is "prod" and change_type is "emergency". With several conditions, each row has failed_assertions, the conditions that failed in the order written and [] when none did, as a check written as several sentences has. d-3 has no change_type, so Ergo can’t tell whether it’s an emergency change, and approved isn’t met. artifact has no from, so it reads the input field artifact, and signed is met.

  • named by $params.name picks one key: from: trail.artifacts named by $params.artifact_name. When the param is missing, null or not a string, there are no subjects and $min_subjects fails as absent, null or unusable, also with every in front of the line. A number is not a key and never reads an item of a list, so x named by $params.k with {"k": 1} fails as unusable.

  • named by each of $params.kinds makes each key in the list a subject: from: build.test_runs named by each of $params.kinds. Each key is a subject, even one the object doesn’t have, and the key is its id, so an identified_by on that subject is written wrong, with not allowed with named by each of: the key is the id. When the object itself is missing, null or not an object, a list included, each key is still a subject, but $min_subjects fails as absent, null or unusable, so the requirement is never met. A child subject whose parent isn’t an object makes no subject of its keys, because nothing below that parent is read: its $min_subjects fails as not_an_object. When the list is missing, isn’t a list, or holds anything but strings, there are no subjects and $min_subjects fails as absent, null or unusable. The list is read from $params or $input. A path that starts anywhere else, like t.keys, keys or .keys, is written wrong, with named by each of takes a list from $params or $input, so it can't start at a field or a subject, so a parent can’t list the keys of its children and nothing is read from that path. A ref before named by each of, like $params.env in scans named by $params.env named by each of $params.kinds, is read first: when it’s missing, null or not a string, $min_subjects fails as absent, null or unusable, whatever the list holds. Whether each key is still a subject then is an open question.

    The path to the list is read as one path and never walks a list, so the keys must be a list in one place. With this policy:

    ergo_spec: v0.6
    subjects:
      scan:
        from: $input.scans named by each of $input.required.kind
    requirements:
      scanned:
        subject: scan
        checks:
          passed: status is "pass"
    

    and this input, where required is a list of objects:

    {
      "scans": {"snyk": {"status": "pass"}, "trivy": {"status": "pass"}},
      "required": [{"kind": "snyk"}, {"kind": "trivy"}]
    }
    

    there are no subjects, and $min_subjects fails:

    {
      "cause": "unusable",
      "check": "$min_subjects",
      "inputs": [{"locations": [], "name": "in-scope scan count", "value": 0}],
      "message": "$input.required is [{\"kind\": \"snyk\"}, {\"kind\": \"trivy\"}], which isn't an object",
      "passed": false,
      "requirement": "scanned",
      "subject": {"id": null, "type": "scan"}
    }
    

    With "required": {"kind": ["snyk", "trivy"]}, both scans are subjects and pass. The $refs of $min_subjects and $unique_ids list $input.required.kind with the value null.

  • A condition can ask about the items of a list with a one-line some or every, written as in a check. With this policy:

    subjects:
      release:
        from: releases whose some files.path does not match "^docs/"
        identified_by: version
    requirements:
      ticketed:
        subject: release
        min_subjects: 0
        checks:
          approved: ticket.status is "approved"
    

    and this input:

    {"releases": [
      {"version": "1.4.0", "files": [{"path": "docs/upgrade.md"}, {"path": "src/api.go"}], "ticket": {"status": "approved"}},
      {"version": "1.4.1", "files": [{"path": "docs/upgrade.md"}], "ticket": {"status": "open"}},
      {"version": "1.4.2", "files": [], "ticket": {"status": "open"}}
    ]}
    

    the $applies rows keep 1.4.0, which changes a file outside docs/, and leave the other two out. They read, and list as failed_items, what the same sentence does as a check:

    {"check": "$applies", "subject": {"id": "1.4.0", "type": "release"}, "passed": true, "cause": "satisfied",
     "inputs": [{"name": "files[].path", "value": ["docs/upgrade.md", "src/api.go"], "locations": ["/releases/0/files/0/path", "/releases/0/files/1/path"]}], "failed_items": []}
    {"check": "$applies", "subject": {"id": "1.4.1", "type": "release"}, "passed": false, "cause": "value", "message": "item 1 of files: path is \"docs/upgrade.md\"",
     "inputs": [{"name": "files[].path", "value": ["docs/upgrade.md"], "locations": ["/releases/1/files/0/path"]}],
     "failed_items": [{"path": "files[0].path", "item": "item 1 of files", "cause": "value", "value": "docs/upgrade.md", "locations": ["/releases/1/files/0/path"]}]}
    {"check": "$applies", "subject": {"id": "1.4.2", "type": "release"}, "passed": false, "cause": "value", "message": "files is []",
     "inputs": [{"name": "files[].path", "value": [], "locations": []}],
     "failed_items": [{"path": "files", "item": "files", "cause": "value", "value": [], "locations": ["/releases/2/files"]}]}
    

    A release with no files field at all isn’t out of scope: Ergo can’t tell, so the row fails as absent (null and unusable for a null or a wrong type), with the input {"name": "files.path", "value": [], "locations": []}, and the requirement isn’t met. every files.path matches "^docs/" keeps the releases that only change documentation, and every files.path, if any, matches "^docs/" keeps the ones with no files too. some takes no , if any,. A bare name after some or every is the item’s, and a parent subject’s name on the right is the parent. The condition can’t have a whose of its own, and a whose in a check or on a for line takes no some or every condition.

  • A subject’s own name at the start of its from is the input field, so lockfile can have from: lockfile whose status is "COMPLETE".

  • A from line that’s another subject’s name, with or without a whose, narrows that subject (a position after the name, like p[1] or first of p, doesn’t narrow it: the subject reads that position inside each p, and fails $min_subjects as unusable when a p is an object), as in a subject built on another: critical defect with from: defect whose severity is "critical" keeps the defects that pass defect’s conditions and its own. A name with a space goes in quotes, so the whole line goes in single quotes in YAML: from: '"critical defect" whose team is "b"'. It takes its id from the subject it narrows, so an identified_by of its own is written wrong, with not allowed on a subject that narrows another. When that subject is read with every, the narrowing subject takes its flat list of subjects and filters it, and its $min_subjects expression starts with every too.

  • A from line with one name, like deployments or .deployments, gives each item of a list as a subject. A line with more than one name needs every to read a list, wherever the list is: $input, another subject’s name, each field and each key a named by reads count as names, and a position like [0] or first of doesn’t. So $input.deployments, build.tests, build named by $params.k and builds[0].tests need every when the value they reach is a list, and builds[0].tests[0] doesn’t. Without it, $min_subjects fails as unusable and says what to write, like trails is a list: write every trails.r.prs to read each item. The data decides whether a value is a list, so the line isn’t written wrong.

  • A from line that ends at text, a number or true or false gives that value as one subject, and a check reads it by the subject’s name: with {"version": "1.2.0"}, version with from: version is a subject, and version is "1.2.0" passes. An input that is itself text still fails $min_subjects as unusable.

  • All the subjects a from line gives must be of one type: objects, lists, text, numbers, or true or false. from: tags on {"tags": ["a", {"x": 1}]} fails $min_subjects as unusable, even with min_subjects: 0, with item 1 of tags is text, item 2 of tags is an object, and both are still subjects and are checked. The row’s failed_items lists the values the message names, item 1 of tags and item 2 of tags. An item that is null is a subject too and doesn’t count as a type. Only that line’s $min_subjects fails: a child subject read from it is still read under each parent, so with d from ds and m from d.m, {"ds": [{"m": {"up": false}}, "x"]} gives m under the first d, and d is "x", which isn't an object under the second.

  • A from line that starts at another subject’s name and goes on reads the rest of its path from that subject. With {"params": {"artifact_name": "web"}} and this input:

    {"trail": {"artifacts": {"web": {"fingerprint": "sha-1", "pull_requests": [
      {"url": "pr/7", "approved_by": "bob"},
      {"url": "pr/8", "approved_by": ""}
    ]}}}}
    

    these subjects read the pull requests of the web artifact:

    subjects:
      artifact:
        from: trail.artifacts named by $params.artifact_name
        identified_by: fingerprint
      pull request:
        from: every artifact.pull_requests
        identified_by: url
    

    The pull requests are a child subject of artifact: they only exist under an artifact that’s in scope, and each one belongs to one artifact. Every row of a requirement on pull request says which, under in, apart from $well_formed, which is about the requirement as written:

    {"check": "approved", "subject": {"type": "pull request", "id": "pr/8", "in": {"artifact": "sha-1"}}, "passed": false, "cause": "value", "message": "approved_by is \"\"",
     "inputs": [{"name": "approved_by", "value": "", "locations": ["/trail/artifacts/web/pull_requests/1/approved_by"]}]}
    

    A check reads the artifact by its name, like artifact.name. Only checks written as sentences can read it.

    So can a whose in the child’s from line. With {"params": {"artifact_name": "web"}} and this input:

    {"trail": {"artifacts": {"web": {"fingerprint": "sha-1", "branch": "main", "pull_requests": [
      {"url": "pr/7", "base": "main"},
      {"url": "pr/8", "base": "dev"}
    ]}}}}
    

    from: every artifact.pull_requests whose base is artifact.branch keeps pr/7 and leaves pr/8 out of scope:

    {"check": "$applies", "subject": {"type": "pull request", "id": "pr/8", "in": {"artifact": "sha-1"}}, "passed": false, "cause": "value", "message": "base is \"dev\", and artifact.branch is \"main\"",
     "inputs": [{"name": "artifact.branch", "value": "main", "locations": ["/trail/artifacts/web/branch"]}, {"name": "base", "value": "dev", "locations": ["/trail/artifacts/web/pull_requests/1/base"]}]}
    

    On the left of a whose, artifact.branch is the pull request’s own field, as in any whose.

    When the artifact is several subjects, the pull requests of each are read and checked in turn. With this input:

    {"trail": {"artifacts": [
      {"fingerprint": "sha-1", "environment": "prod", "pull_requests": [{"url": "pr/7", "approvals": 2}, {"url": "pr/8", "approvals": 0}]},
      {"fingerprint": "sha-2", "environment": "prod", "pull_requests": []},
      {"fingerprint": "sha-3", "environment": "staging", "pull_requests": [{"url": "pr/9", "approvals": 0}]},
      {"fingerprint": "sha-4", "pull_requests": [{"url": "pr/10", "approvals": 1}]}
    ]}}
    

    and these subjects and requirement:

    subjects:
      artifact:
        from: every trail.artifacts whose environment is "prod"
        identified_by: fingerprint
      pull request:
        from: every artifact.pull_requests
        identified_by: url
    requirements:
      reviewed:
        subject: pull request
        checks:
          approved: approvals is at least 1
    

    the report has these rows:

    {"check": "$well_formed",  "subject": {"type": "pull request", "id": null}, "passed": true, "cause": "satisfied"}
    {"check": "$min_subjects", "subject": {"type": "pull request", "id": null, "in": {"artifact": "sha-1"}}, "passed": true, "cause": "satisfied"}
    {"check": "$min_subjects", "subject": {"type": "pull request", "id": null, "in": {"artifact": "sha-2"}}, "passed": false, "cause": "value", "message": "in-scope pull request count is 0"}
    {"check": "$min_subjects", "subject": {"type": "pull request", "id": null, "in": {"artifact": "sha-4"}}, "passed": false, "cause": "absent", "message": "in-scope pull request count is 0"}
    {"check": "$unique_ids",   "subject": {"type": "pull request", "id": null, "in": {"artifact": "sha-1"}}, "passed": true, "cause": "satisfied"}
    {"check": "$unique_ids",   "subject": {"type": "pull request", "id": null, "in": {"artifact": "sha-2"}}, "passed": true, "cause": "satisfied"}
    {"check": "$unique_ancestor_ids", "subject": {"type": "artifact", "id": null}, "passed": true, "cause": "satisfied"}
    {"check": "approved",      "subject": {"type": "pull request", "id": "pr/7", "in": {"artifact": "sha-1"}}, "passed": true, "cause": "satisfied"}
    {"check": "approved",      "subject": {"type": "pull request", "id": "pr/8", "in": {"artifact": "sha-1"}}, "passed": false, "cause": "value", "message": "approvals is 0"}
    
    • Each artifact gets its own $min_subjects and $unique_ids rows. sha-2 has no pull requests, so it fails on its own row, unless the requirement has min_subjects: 0. Ids only need to be unique within each artifact, so pr/7 under two artifacts is fine.

    • The artifacts’ own ids are checked once, on the $unique_ancestor_ids row, with no in, counting sha-3 too, as $unique_ids counts the subjects its conditions leave out. Two artifacts with the same fingerprint fail it, and so does the requirement, even with require: some, where the pull requests of both artifacts would otherwise count as one artifact’s. With commit read from commits by sha, pull request from every commit.pull_requests, and two commits that both have the sha a, the row is:

      {"check": "$unique_ancestor_ids", "subject": {"type": "commit", "id": null}, "passed": false, "cause": "value", "message": "repeated commit ids is [\"a\"]",
       "inputs": [{"name": "repeated commit ids", "value": ["a"], "locations": ["/commits/0/sha", "/commits/1/sha"]}]}
      
    • sha-3 is out of scope, so its pull requests aren’t subjects at all and it has no rows. Nothing in them is read, so pr/9 isn’t checked.

    • sha-4 has no environment, so Ergo can’t tell whether it’s in scope. Its $min_subjects row fails with the cause, even with min_subjects: 0, and its pull requests aren’t checked.

    • When no artifact is in scope, there’s one $min_subjects row with no in. It fails with value, or passes with min_subjects: 0, as for any subject that finds none.

    A child subject can have child subjects of its own, so a chain can be three subjects deep, like artifact, pull request and commit:

    commit:
      from: 'every "pull request".commits'
      identified_by: sha
    

    Each commit row says which artifact and which pull request it belongs to, like "in": {"artifact": "sha-1", "pull request": "pr/7"}, and each pull request gets its own $min_subjects and $unique_ids rows. The requirement gets one $unique_ancestor_ids row for the artifacts, with no in, then one for the pull requests of each artifact in scope, with the artifact under in, and the checks entry joins both: Every artifact id is unique and every pull request id is unique. An artifact or a pull request that’s out of scope leaves out everything below it. An artifact whose conditions can’t be decided gets one failing $min_subjects row, with "in": {"artifact": ...}. A check reads any ancestor by its name, like artifact.name or "pull request".title, and its parts too. A fourth level is written wrong, with a chain of more than three subjects isn't supported yet, and so is a subject whose from line narrows a child subject. A chain that comes back to a subject already in it, like b with from: kid.x and kid with from: b.kids, is written wrong with leads to a loop, however many subjects it goes through.

    A parent that isn’t an object, a string or a null for instance, can’t hold the child’s field, so its $min_subjects row fails as not_an_object, with in up to the parent, even with min_subjects: 0. A parent that’s an object without the field still gives absent, and a from that starts with a position, like artifact[0].commits, keeps the cause it had. A parent that is a list is read with every: with batch read from batches, every batch.owner gives the owners in each batch, with the batch under in, and batch.owner fails as not_an_object with batch is a list: write every batch.owner to read each item. every never stands for the parent itself: every batch and every batch whose size is 2 are written wrong, with batch is a subject: write batch whose โ€ฆ to narrow it, without every.

    Use a child subject when something only makes sense inside another thing, like the test runs of a build. Its id only has to be unique inside its parent, so unit can appear in every build, and every row names the parent under in, so a reader knows which build a failing test run belongs to. With {"params": {"kinds": ["unit", "integration"]}} and this input:

    {"builds": [
      {"id": "b1", "test_runs": {"unit": {"passed": true}, "integration": {"passed": true}}},
      {"id": "b2", "test_runs": {"unit": {"passed": true}, "integration": {"passed": false}}}
    ]}
    

    and this policy:

    subjects:
      build:
        from: builds
        identified_by: id
      test run:
        from: build.test_runs named by each of $params.kinds
    requirements:
      tests pass:
        subject: test run
        checks:
          passed: passed is true
    

    there are four test runs, unit and integration under each build, and only integration in b2 fails:

    {"check": "passed", "subject": {"type": "test run", "id": "integration", "in": {"build": "b2"}}, "passed": false, "cause": "value", "message": "passed is false"}
    

    every can’t go with named by each of, so every builds.test_runs named by each of $params.kinds is written wrong: the keys would be the ids, and with two builds the rows wouldn’t say which build they belong to. When a from line meets a list before named by each of, like build.test_runs with test_runs a list, $min_subjects fails as unusable with build.test_runs is a list, but named by each of reads the keys of one object: make each item a subject and read the keys in it, and each key is still a subject that fails its checks. The same goes for $input named by each of $params.kinds when the input is a list, which says $input is a list, but โ€ฆ. A parent subject that is a list, under artifact named by each of $params.kinds, fails as not_an_object with artifact is a list, but โ€ฆ.

  • A from line that starts at another subject’s name and ends with named by each of, like artifact named by each of $params.keys, is a child subject too: each key in the list is a subject read inside each parent, so the keys are looked up in the parent and not in an input field named like it, and every row carries the parent under in. artifact.x named by each of $params.keys reads the keys inside x of each parent. A parent that isn’t an object fails $min_subjects as not_an_object and makes no subject of its keys. A missing, null or wrong-typed list fails $min_subjects of each parent as absent, null or unusable. A first key written with a dot, like .artifact named by each of $params.keys or .artifact.pull_requests, is always the input field, never the parent, so that line isn’t a child subject. A position after the parent’s name, like artifact[0] named by each of $params.keys or first of artifact named by each of $params.keys, reads the parent as a list: $min_subjects fails as unusable for each parent that’s an object, and the keys are still subjects that fail their checks.

  • every in front of the path reads one list at each name along it, the last one included, and at the value it starts at, and each value it ends at is a subject. With this input:

    {"artifacts": [
      {"fingerprint": "sha-1", "pull_requests": [{"url": "pr/7", "approvals": 2}, {"url": "pr/8", "approvals": 0}]},
      {"fingerprint": "sha-2", "pull_requests": []},
      {"fingerprint": "sha-3"}
    ]}
    

    and this policy:

    subjects:
      pull request:
        from: every artifacts.pull_requests
        identified_by: url
    requirements:
      reviewed:
        subject: pull request
        checks:
          approved: approvals is at least 1
    

    the pull requests are one list, pr/7 and pr/8, and the report has these rows after $well_formed:

    {"check": "$min_subjects", "subject": {"type": "pull request", "id": null}, "passed": false, "cause": "absent", "message": "item 3 of artifacts: pull_requests is missing",
     "inputs": [{"name": "in-scope pull request count", "value": 2, "locations": ["/artifacts/0/pull_requests/0", "/artifacts/0/pull_requests/1"]}],
     "failed_items": [{"path": "artifacts[2].pull_requests", "item": "artifacts[2].pull_requests", "cause": "absent", "value": null, "locations": ["/artifacts/2"]}]}
    {"check": "$unique_ids",   "subject": {"type": "pull request", "id": null}, "passed": true,  "cause": "satisfied"}
    {"check": "approved",      "subject": {"type": "pull request", "id": "pr/7"}, "passed": true,  "cause": "satisfied"}
    {"check": "approved",      "subject": {"type": "pull request", "id": "pr/8"}, "passed": false, "cause": "value", "message": "approvals is 0"}
    
    • Unlike a child subject, these pull requests don’t belong to an artifact. Their rows have no in, they’re counted once, and their ids must be unique across all of them. A check can’t read the artifact.
    • An empty list, like sha-2’s, adds nothing and fails nothing.
    • A list that’s missing, like sha-3’s, or null fails $min_subjects with the worst cause found, even with min_subjects: 0. The row’s failed_items says where, and the pull requests that could be read are still checked. So does a path with no list along it: every deps on {"deps": {"id": 1}} fails with deps is {"id": 1}, which isn't a list.
    • A value at the end can be an object, text, a number or true or false: every builds.version on {"builds": [{"version": "1.0"}, {"version": "1.1"}]} gives "1.0" and "1.1".
    • A name every reads as a list in one item must be a list in all of them. every builds.tags on {"builds": [{"tags": ["a", "b"]}, {"tags": "c"}]} gives a, b and c, and $min_subjects fails as unusable with tags is a list in item 1 of builds but "c" in item 2, and lists builds[0].tags and builds[1].tags in failed_items. A policy that expects both shapes can say so with a subject for each, like listed build with from: build whose tags is a list and tag with from: every "listed build".tags.
    • A list directly inside a list is an item like any other, and every doesn’t read into it. With {"batches": [[{"owner": {"name": "ann"}}], [{"owner": {"name": "cy"}}]]}, every batches gives two subjects, each a list, and every batches.owner fails as not_an_object, because a list has no owner. A subject for each level, batch and every batch.owner, reads the owners. Text, a number or true or false where a name is read fails as unusable instead, with the message a check gives: every builds.version.x on {"builds": [{"version": "1.0"}]} says item 1 of builds: version is "1.0", which isn't an object.
    • every can start at another subject’s name, like every artifact.pull_requests, which reads the pull requests of each artifact, with the artifact under in, as a child subject does. every can’t go with named by each of, which is written wrong. A path that starts with a dot, like every .artifact.p, reads the input field.
  • every distinct makes one subject of items that are exactly the same, which SBOM tools often write twice. With this input:

    {"sbom": {"components": [
      {"name": "click", "version": "8.1"},
      {"name": "jinja2", "version": "3.1"},
      {"name": "click", "version": "8.1"}
    ]}}
    

    and this policy:

    subjects:
      component:
        from: every distinct sbom.components
        identified_by: name
    requirements:
      versioned:
        subject: component
        checks:
          version: version exists
    

    there are two subjects, and each row of click lists both copies, so the report still shows it was there twice:

    {"check": "$min_subjects", "subject": {"type": "component", "id": null}, "passed": true, "cause": "satisfied",
     "inputs": [{"name": "in-scope component count", "value": 2, "locations": ["/sbom/components/0", "/sbom/components/2", "/sbom/components/1"]}]}
    {"check": "$unique_ids", "subject": {"type": "component", "id": null}, "passed": true, "cause": "satisfied"}
    {"check": "version", "subject": {"type": "component", "id": "click"}, "passed": true, "cause": "satisfied",
     "inputs": [{"name": "version", "value": "8.1", "locations": ["/sbom/components/0/version", "/sbom/components/2/version"]}]}
    {"check": "version", "subject": {"type": "component", "id": "jinja2"}, "passed": true, "cause": "satisfied",
     "inputs": [{"name": "version", "value": "3.1", "locations": ["/sbom/components/1/version"]}]}
    
    • Two values are the same when they’re equal as JSON: 1 and 1.0 are the same, and so are two objects with the same keys in another order. Two lists with the same items in another order aren’t.
    • Items that share an id but differ in anything else, like a third click with version 8.2, are all kept, and $unique_ids fails on them and lists the location of every copy’s id. Ergo never picks one of two pieces of evidence that disagree, so there’s no distinct by a field.
    • Copies are merged before the whose is read, so they get one $applies row, and the rows of a child subject, like from: every component.licenses, list the location under every copy too. A path that starts at $input, like version is $input.sbom.components[2].version, lists only the location it names.
    • distinct goes after every on a from line. Without every, like from: distinct sbom.components, it is written wrong.
  • A from or identified_by that can’t be read is written wrong, and the $well_formed row says why, like {"name": "subjects.x.from", "value": ["doesn't parse"], "locations": []}.

  • A from that can’t be read fails $min_subjects, and its message says where the read stopped: item 2 of things is missing, or things is 5, which isn't a list or an object. A child subject names the path inside its parent, without the parent’s name, like ws is missing or item 1 of ws is missing. When the read stops before its first step, the message names $input, like $input is null, or the parent, like artifact is null, artifact is "x", which isn't an object or item 1 of artifact is missing for a child of artifact.

A subject built on another

When requirements check different parts of the same list, give each part its own subject, built on one that says where the list is. Say the SBOM lists components, some pinned by the lockfile and some exempt from the licence rules:

ergo_spec: v0.6
subjects:
  component:
    from: components
    identified_by: name
  locked component:
    from: component whose lock_release exists
  non-exempt component:
    from: component whose exempt is false
requirements:
  versions:
    subject: locked component
    checks:
      matches_lock: sbom_release equals lock_release
  licences:
    subject: non-exempt component
    checks:
      licence_known: some licences is a non-empty string

With this input:

{"components": [
  {"name": "a", "sbom_release": "1.2", "lock_release": "1.2", "exempt": false, "licences": ["MIT"]},
  {"name": "b", "sbom_release": "2.0", "lock_release": "2.1", "exempt": true, "licences": []},
  {"name": "c", "sbom_release": "3.0", "exempt": false, "licences": [""]}
]}

licences checks a and c, and versions checks a and b. Both fail:

requirementsubjectcheckpassedcause
licencesalicence_knowntruesatisfied
licencesclicence_knownfalsevalue
versionsamatches_locktruesatisfied
versionsbmatches_lockfalsevalue

A from line that’s another subject’s name reads that subject’s from and identified_by, and keeps only the items that pass that subject’s conditions and its own. Everything else about it is its own: its rows and descriptions use its name, like locked component.

  • A subject can build on one that builds on another, and so on. A name with a space goes in quotes, so the whole line goes in single quotes in YAML: from: '"locked component" whose exempt is false'.
  • A chain that comes back to a subject already in it never reaches a list. It fails as leads to a loop.
  • A from line can’t start at a subject whose name is blank, like " ", since no requirement can name it. It fails as " " isn't a subject name.
  • A problem fails $well_formed on every requirement that names a subject whose chain has it, and that row is the requirement’s only row.
  • A requirement can name the subject others build on too, like component to check every component.

Parts

Data often keeps what a check needs deep inside the subject. Kosli, for one, puts every attestation at compliance_status.attestations_statuses.<name>.attestation_data, so a policy that reads three attestations repeats that path in every check. A subject can give a path a name once, with has, and the checks use the name. Each entry of has is a part, and its key is the part’s alias. An alias is a short word that stands for something longer, as as pr does on a for line.

ergo_spec: v0.6
subjects:
  round:
    from: trail
    identified_by: name
    has:
      findings: compliance_status.attestations_statuses.findings.attestation_data
      coverage:
        from: compliance_status.attestations_statuses.coverage-verification
        identified_by: attestation_id
requirements:
  review_round:
    subject: round
    checks:
      complete: findings.truncated is false
      every_hunk_acknowledged: coverage.attestation_data.hunks_acknowledged equals coverage.attestation_data.hunks_total

With this input:

{
  "trail": {
    "name": "round-1",
    "compliance_status": {
      "attestations_statuses": {
        "findings": { "attestation_data": { "truncated": false } },
        "coverage-verification": {
          "attestation_id": "cov-9",
          "attestation_data": { "hunks_acknowledged": 25, "hunks_total": 28 }
        }
      }
    }
  }
}

complete passes and every_hunk_acknowledged fails with cause value:

{"check": "complete", "subject": {"type": "round", "id": "round-1"}, "passed": true, "cause": "satisfied",
 "inputs": [{"name": "findings.truncated", "value": false, "locations": ["/trail/compliance_status/attestations_statuses/findings/attestation_data/truncated"], "part": {"name": "findings", "id": null}}]}
{"check": "every_hunk_acknowledged", "subject": {"type": "round", "id": "round-1"}, "passed": false, "cause": "value", "message": "coverage.attestation_data.hunks_acknowledged is 25, and coverage.attestation_data.hunks_total is 28",
 "inputs": [{"name": "coverage.attestation_data.hunks_acknowledged", "value": 25, "locations": ["/trail/compliance_status/attestations_statuses/coverage-verification/attestation_data/hunks_acknowledged"], "part": {"name": "coverage", "id": "cov-9"}},
            {"name": "coverage.attestation_data.hunks_total", "value": 28, "locations": ["/trail/compliance_status/attestations_statuses/coverage-verification/attestation_data/hunks_total"], "part": {"name": "coverage", "id": "cov-9"}}]}

Each input read through a part says which part, and which one of them: part has the alias as the check writes it, and the id, the value at the part’s identified_by inside the part. That is how a reader tells one attestation from another when a subject has several of the same kind. A one-line part has no id, so its id is null, and so is the id of a part that is missing or has nothing at identified_by. An input that doesn’t go through a part has no part.

A part is a short name for its path and nothing more. coverage.attestation_data.hunks_total gives exactly the rows that compliance_status.attestations_statuses.coverage-verification.attestation_data.hunks_total gives: the same passed, the same cause and the same values. A part that is missing fails the check as absent, a null one as null, and one of the wrong type as unusable, because the full path would. When a step follows a null part, the message names the part, like findings is null for findings.truncated is false. A key read from a field after the null is named too, like pl is null; p is 3, which isn't text for pl.z named by p exists with p 3. Only the names differ. With coverage-verification missing from the input, the row is

{"check": "every_hunk_acknowledged", "subject": {"type": "round", "id": "round-1"}, "passed": false, "cause": "absent", "message": "coverage.attestation_data.hunks_acknowledged is missing, and coverage.attestation_data.hunks_total is missing",
 "inputs": [{"name": "coverage.attestation_data.hunks_acknowledged", "value": null, "locations": ["/trail/compliance_status/attestations_statuses"], "part": {"name": "coverage", "id": null}},
            {"name": "coverage.attestation_data.hunks_total", "value": null, "locations": ["/trail/compliance_status/attestations_statuses"], "part": {"name": "coverage", "id": null}}]}

and written out in full, its inputs are named compliance_status.attestations_statuses.coverage-verification.attestation_data.hunks_acknowledged and ...hunks_total, with the same null. The expression of a check and the names in its inputs and failed_items keep the alias, so a reader sees the words the policy was written in.

  • A part is one line, a path, or an object with from, a path, and identified_by, a path read inside the part. The report shows it under parts, and tags each input read through the part with the id it leads to. A count of or sum of has the tag only when the list it counts or sums starts at the part: count of items whose w is pa.w counts the subject’s items, so its number has no tag, while the entry for pa.w, which it reads, has.

  • A path is written as in a from line: keys joined by ., keys in quotes, positions, and named by a key of $params or $input. It is a path inside the subject, so it can’t start at $params or $input, and it can’t have whose, count of or named by each of.

  • A part can be any path, even one that ends at a single value: ratio: compliance_status.attestations_statuses.coverage.attestation_data.ratio makes ratio is at least 0.8 read the same.

  • Keep the data’s own names, as findings and coverage do, because a reader who knows the data finds coverage.attestation_data.hunks_total where they’d look for it.

  • An alias is read wherever a bare first key reads the subject: in the subject’s checks, in the assertions of a for line, and in the conditions of the subject’s from line and of a subject that narrows it, where .limit reads the field instead. In a whose, where a bare first key is a field of the item, it’s read on the right side and never right after whose, as a for line’s name is: whose limit is at least 4 reads the item’s limit, and .limit on the right of a whose reads the item’s. After a one-line every or some, the right side is read from the subject, so every items.price is at most limit reads the part limit, and .limit there reads the subject’s own field. A term of a plus and the path of an elapsed time on the right read it the same way, so whose lo is at least limit plus hi reads the part limit and the item’s hi. A count of or sum of is read the same way: in count of items whose n is count of limit.rows is 1 the right side counts the part’s limit.rows, and in count of items whose count of limit.rows is 1 is 3 the left side counts each item’s own limit.rows. After that, count of xs that is count of limit.rows is 1 counts the part’s limit.rows too, even when the items are numbers. named by a part’s alias is written wrong, with named by coverage reads a part, which isn't supported yet.

  • An alias shadows a field of the subject with the same name. To read the field, put a dot in front: .coverage is the subject’s field coverage, not the part. A row’s inputs name a field read like that with the dot, like .coverage.pct, when the field is named like a part, a parent subject or a for alias, so it isn’t taken for the part’s coverage.pct. The list a count of or a walk reads keeps the dot too, like .coverage.items[].

  • An alias is a bare word that isn’t a word of the grammar, params or input, or it is written wrong with invalid alias. A part’s alias named like a subject of the policy (its own, its parent or any other) is written wrong with <alias> can't be an alias: it's a subject's name, and one named like a part of a parent or grandparent subject, or like a part kept from a subject it narrows, with <alias> can't be an alias: it's a part of <subject>, which names the subject that declares the part and puts a name with a space in it in double quotes, like "pull request". A name always means one thing, so nobody has to wonder whether it reads the subject or the alias. The problem is listed under the part, like subjects.kid.has.x.

  • An alias of a for line can’t be named like a subject, the check’s own, a parent or any other in the policy, or like a part of the check’s own subject, of a parent or grandparent subject, or of a subject they narrow. Either is written wrong, so that a name always means one thing and a bare name never reads the parent when the item lacks the field. The problem is under the check, as <alias> can't be an alias: it's a subject's name or <alias> can't be an alias: it's a part of <subject>, and both are listed when both apply. With this policy and this input:

    subjects:
      b:
        from: bs
        identified_by: id
        has:
          cap: limit
      kid:
        from: every b.kids
        identified_by: id
    requirements:
      r:
        subject: kid
        checks:
          c:
            for: every xs as b
            assert: b.v is 1
    
    {"bs": [{"id": "b1", "limit": 2, "kids": [{"id": "k1", "xs": [{"v": 1}]}]}]}
    

    the $well_formed row fails with {"name": "checks.c", "value": ["b can't be an alias: it's a subject's name"], "locations": []}, and with as cap it fails with ["cap can't be an alias: it's a part of b"]. A name that clashes with nothing, like as entry, reads the item.

  • A subject that narrows another keeps its parts and can add its own. The report lists them all.

  • A child subject reads its parent’s parts through the parent’s name, and its from line can start from one. With {"params": {"artifact_name": "web"}} and this input:

    {"trail": {"artifacts": {"web": {"fingerprint": "sha-1", "attestations_statuses": {
      "pull-request": {"attestation_data": {"approvers": ["bob"], "pull_requests": [{"url": "pr/7"}, {"url": "pr/8"}]}}}}}}}
    

    this policy reads the pull requests of the attestation, and checks each of them against its approvers:

    subjects:
      artifact:
        from: trail.artifacts named by $params.artifact_name
        identified_by: fingerprint
        has:
          pr_attestation: attestations_statuses.pull-request.attestation_data
      pull request:
        from: every artifact.pr_attestation.pull_requests
        identified_by: url
    requirements:
      approved:
        subject: pull request
        checks:
          approved: artifact.pr_attestation.approvers is not empty
    
    {"check": "approved", "subject": {"type": "pull request", "id": "pr/7", "in": {"artifact": "sha-1"}}, "passed": true, "cause": "satisfied",
     "inputs": [{"name": "artifact.pr_attestation.approvers", "value": ["bob"], "locations": ["/trail/artifacts/web/attestations_statuses/pull-request/attestation_data/approvers"], "part": {"name": "artifact.pr_attestation", "id": null}}]}
    

    A part is only reached through its subject, so a bare pr_attestation in a check of pull request is one of its own fields.

  • A part that is written wrong fails $well_formed on every requirement that names the subject, with the problem under the part’s alias, like {"name": "subjects.round.has.coverage", "value": ["missing from"], "locations": []}. That is a has that isn’t an object (not an object), a part that isn’t a string or an object (not a string or an object), an object without from (missing from), with a field other than from and identified_by (unknown field <name>), or with a from or identified_by that isn’t a string (invalid from, invalid identified_by), and a path that can’t be read (from: a part is a path: whose and named by each of can't go in it, from: a part reads inside its subject, so it can't start at $params or $input, or the problems a from line gives).

  • The report lists every part under the subject’s parts, including the ones it keeps from a subject it narrows.

Requirements

prod_deploy:
  description: Every production deployment is approved
  meta: {control: SDLC-CTRL-0007, frameworks: [SOC 2]}
  subject: deployment
  require: every
  min_subjects: 1
  checks:
    approved: approved_by is a non-empty string
FieldMeaningDefault
descriptionWhat the requirement asks for, in words anyone can read. It’s copied into the report.""
metaAnything else you want to keep with the requirement, like a control id or an owner. It’s copied into the report. Ergo never reads it.{}
subjectThe name of the subject it checks.required
require"every": every subject must pass every check. "some": at least one subject must pass every check."every"
min_subjectsHow many subjects must be left after the subject’s conditions for the requirement to be met, from 0 to 1,000,000.1
checksNamed checks that each subject must pass.required

A few details:

  • meta can hold anything JSON can, like {"control": "SDLC-CTRL-0007", "frameworks": ["SOC 2", "ISO 27001"], "version": 3, "reviewed": true}. Its numbers follow the same rule as numbers anywhere else in a policy: one a 64-bit float can’t hold, like 1e400, makes the requirement written wrong. So does a key that isn’t a string, or a set, which a policy written in Rego can hold but JSON can’t. In the report, a number is written in its plain form, so 1.50, 1e2 and -0 come out as 1.5, 100 and 0, and the same meta gives the same report however it was written.
  • min_subjects defaults to 1 so that a typo in from fails the requirement instead of quietly passing it. Set it to 0 when you mean “if there are any, they must pass; if there are none, that’s fine”. It means the same under every and some. When none are left, the requirement is not applicable. That only holds for a list that’s there: a from that leads nowhere still fails, as above. A from that ends with named by each of is the exception, because each key is a subject even when from leads nowhere.
  • Under some, one subject has to pass all the checks by itself. Two subjects that each pass half of them don’t count. For a child subject, that holds under each parent: every parent with subjects in scope needs one that passes, and the failing subjects under a parent that has a passing one aren’t violations.
  • A requirement with no checks, a require other than every or some, a check that’s written wrong, or a subject that’s written wrong, is never met. The $well_formed row says so, and says what’s wrong, like {"name": "require", "value": ["neither every nor some"], "locations": []}. That row is the requirement’s only row: Ergo doesn’t read its subjects, so it has no $min_subjects, $unique_ids, $unique_ancestor_ids, $applies or check rows, and its subjects counts are 0. Its entry in requirements still lists every check, the ones Ergo adds included, so the report shows what the policy wrote. Other requirements in the policy still get theirs.
  • So is a name that starts with $, whether it names a subject, a requirement, a check, an any_of option or a part, because the report would mix it up with Ergo’s own rows, like $well_formed. A $ later in a name, like a$b, is fine, and so is $input or $params at the start of a path, which aren’t names. The $well_formed row says {"name": "checks.$foo", "value": ["a name can't start with $"], "locations": []} for a check, subjects.$foo for a subject, subjects.deployment.has.$foo for a part and requirement name for a requirement, and for an option it says option $foo: a name can't start with $ under the check. A check with such a name has no entry in requirements. A for alias can’t start with $ either: the line doesn’t parse. A name can’t start or end with a space or an invisible character either, like " $well_formed", "approved " or a zero width space (U+200B) before a name: the problem is a name can't start or end with a space or an invisible character, listed in the same places, and a name with both problems lists both. A space inside a name, like x y, is fine. The characters are the ones Unicode calls White_Space or Cf, as names.edge says. An empty name, "", is written wrong too, with a name can't be empty in the same places, as names.empty says. A requirement whose subject is "" gets empty or not a string under subject.
  • So is a requirement without a subject, or whose subject isn’t in subjects, like a typo for deploymnt. The $well_formed row says {"name": "subject", "value": ["missing"], "locations": []} or {"name": "subject", "value": ["not in subjects"], "locations": []}. If subjects itself isn’t an object, every requirement that names a subject fails with {"name": "subjects", "value": ["not an object"], "locations": []}.
  • So is a requirement with a field that isn’t in the table above, like from or requires, because Ergo would otherwise ignore it and check something you didn’t mean. from and identified_by belong to the subject. The $well_formed row lists each one in order and says what’s wrong with it, the way it does for a check that’s written wrong, like {"name": "from", "value": ["unknown field"], "locations": []}.
  • So is a requirement that isn’t an object, or whose checks isn’t an object, whose min_subjects isn’t a whole number from 0 to 1,000,000, whose subject isn’t a string with something besides whitespace in it, since rows and descriptions name the subject by it, whose description isn’t a string, or whose meta isn’t an object. null counts as the wrong type, except for description and meta: Ergo only copies those, so a null one counts as not written, which is what YAML gives for an empty description:. 2.0 is a whole number, but -1 and 0.5 aren’t, and 1000001 is too big. It still has its entry in requirements and its $well_formed row, which shows each such field and what it should be, like {"name": "checks", "value": ["not an object"], "locations": []}, or the whole requirement and its value, like {"name": "requirement", "value": 5, "locations": []}.

A requirement that only applies sometimes

Some controls only apply to some inputs, like “new features must be tested”, which doesn’t apply to a bug fix. Make the thing that decides it the subject, and keep it only when the control applies, with whose. This one reads a param:

params:
  untested_change_types: [bug_fix, refactor]
subjects:
  deployment:
    from: $input whose deployment.change_type is not in $params.untested_change_types
requirements:
  features_tested:
    subject: deployment
    min_subjects: 0
    checks:
      tested: some test_runs.status exists
      passed: every test_runs.status is "passed"

With {"deployment": {"change_type": "bug_fix"}, "test_runs": []}, the deployment is out of scope, and the report says so:

"compliant": true,
"requirements": {
  "features_tested": {
    "status": "not_applicable",
    "subjects": { "matching": 0, "total": 1 },
    ...

The $applies row reads {"name": "$params.untested_change_types", "value": ["bug_fix", "refactor"], "locations": ["$params:/untested_change_types"]} and {"name": ".deployment.change_type", "value": "bug_fix", "locations": ["/deployment/change_type"]}, and the $applies entry in checks records the list it was compared with too, under $refs: {"name": "$params.untested_change_types", "value": ["bug_fix", "refactor"], "locations": ["$params:/untested_change_types"]}. A new feature with no test runs fails tested and passed, and so does a change type that isn’t listed, like hotfix, so a new or misspelt type has to be tested until someone adds it to the list. A deployment with no change_type fails the requirement, because Ergo can’t tell whether it applies.

The condition lists the types that don’t need tests, rather than the ones that do. deployment.change_type is "new_development" would also work, but then any other type, misspelt ones included, would quietly skip the tests.

Don’t make the test runs the subject instead, with from: test_runs and min_subjects: 0. That lets a new feature with no test runs pass, because there’s nothing to check.

Picking the subject

The subject is what each row is about, so it’s the first choice to make, and it changes more than the rows. Take “every commit was reviewed in an approved pull request”, with this input:

{"commits": [
  {"sha": "a1", "pull_requests": [{"number": 7, "approved_by": "bob"}, {"number": 8}]},
  {"sha": "a2", "pull_requests": []}
]}

With the commit as the subject, the check looks through its pull requests:

ergo_spec: v0.6
subjects:
  commit:
    from: commits
    identified_by: sha
requirements:
  reviewed:
    subject: commit
    checks:
      reviewed:
        description: Some pull request for the commit was approved
        assert: some pull_requests.approved_by is a non-empty string
subjectcheckpassedcause
a1reviewedtruesatisfied
a2reviewedfalsevalue

The requirement is not_met: a2 has no pull request.

With the pull request as the subject, the same sentence turns into “some pull request was approved”:

ergo_spec: v0.6
subjects:
  pull request:
    from: every commits[0].pull_requests
    identified_by: number
requirements:
  reviewed:
    subject: pull request
    require: some
    checks:
      approved:
        description: The pull request was approved
        assert: approved_by is a non-empty string
subjectcheckpassedcause
7approvedtruesatisfied
8approvedfalseabsent

The requirement is met. Pull request 7 is approved, and a2 is never looked at, because from reads one commit’s pull requests. Same input, same words, opposite answers.

Read the pull requests as a child subject of the commit instead, and require: some is decided for each commit. With this policy and {"commits": [{"sha": "a1", "pull_requests": [{"number": 7, "approved_by": "bob"}, {"number": 8}]}, {"sha": "a2", "pull_requests": [{"number": 9}]}]}:

ergo_spec: v0.6
subjects:
  commit:
    from: commits
    identified_by: sha
  pull request:
    from: every commit.pull_requests
    identified_by: number
requirements:
  reviewed:
    subject: pull request
    require: some
    checks:
      approved:
        description: The pull request was approved
        assert: approved_by is a non-empty string
subjectincheckpassedcause
7commit: a1approvedtruesatisfied
8commit: a1approvedfalseabsent
9commit: a2approvedfalseabsent

The requirement is not_met, and its causes is [{"cause": "absent", "rows": 1}]. Commit a1 has an approved pull request, so pull request 8 isn’t a violation, but a2 has none, so pull request 9 is the only violation, with in {"commit": "a2"}. A commit with no pull request fails its own $min_subjects row, or is skipped with min_subjects: 0. With a chain of more than two subjects, each direct parent needs a passing subject. To ask for one anywhere instead, read the pull requests from the input, like every commits.pull_requests: their rows have no in, and some counts across all of them.

So before writing a check, decide:

  • What would the policy’s owner list? The report has one row per subject, so the subject is what it lists. A rule about commits should list commits, even when the evidence is organised by pull request.
  • What id would they cite? The id that identified_by picks is how a row is found again, and how two reports are compared.
  • How many lists sit between the subject and the values compared? every and some walk them all, but a check that compares fields at two levels needs a for line, which takes two lists at most. A check that needs more usually means the subject is one list too high, or the evidence was recorded at the wrong grain. Moving the subject down flattens the check, but then the report lists the smaller thing, as above.
  • Does require still say what the sentence says? “Every commit has some approved pull request” is every over commits with some inside a check. With the pull request as a subject read from the input it becomes some over all pull requests, which is only the same rule when there is one commit. As a child subject of the commit it is the same rule.

A policy can hold several requirements with different subjects over the same input, like a lockfile and each of its entries. Each requirement picks its own grain.

Checks

A check is a sentence: one line of English. spec/syntax.md has the full grammar. Write the sentence on its own, or under assert when the check has a description or meta:

ergo_spec: v0.6
subjects:
  deployment:
    from: deployments
    identified_by: id
requirements:
  prod_deploy:
    subject: deployment
    checks:
      approver: approved_by is a non-empty string
      reviewed:
        description: The change was reviewed before it was deployed
        assert: reviewed_at is before deployed_at
      region: region is in $params.regions
      tickets: tickets contains all of 'OPS-1'

With the params {"regions": ["eu-west-1"]} and this input:

{
  "deployments": [
    {"id": "d1", "approved_by": "ann", "reviewed_at": "2026-10-01T09:00:00Z", "deployed_at": "2026-10-01T10:00:00Z", "region": "eu-west-1", "tickets": ["OPS-1"]},
    {"id": "d2", "approved_by": "", "reviewed_at": "2026-10-02T11:00:00Z", "deployed_at": "2026-10-02T10:00:00Z", "region": "us-east-1", "tickets": []}
  ]
}

d1 passes every check and d2 fails every one. Each check’s entry in the report has its description, meta and expression, and the expression is the sentence:

"reviewed": {
  "description": "The change was reviewed before it was deployed",
  "expression": "reviewed_at is before deployed_at",
  "meta": {}
},
"tickets": {
  "description": "",
  "expression": "tickets contains all of \"OPS-1\"",
  "meta": {}
}

The report prints every sentence one way, so 'OPS-1' shows as "OPS-1", two spaces as one, is equal to "a" as is "a", and is before a field as equals. The rows are the rows of the operator each sentence runs as, so d2’s reviewed row reads both fields:

{
  "cause": "value",
  "check": "reviewed",
  "inputs": [
    {"locations": ["/deployments/1/reviewed_at"], "name": "reviewed_at", "value": "2026-10-02T11:00:00Z"},
    {"locations": ["/deployments/1/deployed_at"], "name": "deployed_at", "value": "2026-10-02T10:00:00Z"}
  ],
  "message": "reviewed_at is \"2026-10-02T11:00:00Z\", and deployed_at is \"2026-10-02T10:00:00Z\"",
  "passed": false,
  "requirement": "prod_deploy",
  "subject": {"id": "d2", "type": "deployment"}
}

A sentence is a path, then a phrase, then what the phrase takes:

SentenceRuns as
env is "prod", also equals and is equal toequals
digest equals fingerprint, a field on the rightcompare with eq
cost is 2.5 within 0.01, cost equals total within 0.01compare with eq and within
env is not "prod", author does not equal approvercompare with ne, so two values of different types fail as unusable
size is not 100 within 5, size does not equal limit within 5compare with ne and within
env is one of "dev", "test", user is in $params.staffin
env is not one of "prod", user is not in $params.botsnot_in
ticket exists, ticket does not existpresent, missing
approved_by is a non-empty stringnon_empty_string
approved_by is not a non-empty stringnot_non_empty_string
tags is a list, config is an objecthas_type: list, string, number, boolean or object
tags is not a list, config is not an objectlacks_type
findings is empty, tickets is not emptyempty_string_or_list, not_empty_string_or_list
labels contains "bug", labels does not contain "wip"includes, excludes with value
labels contains all of "a", "b", labels contains none of ...includes, excludes with values
labels contains some of "a", "b", labels does not contain all of ...includes_some, not_includes_all
branch matches '^release/', branch does not match ...matches_any, not_matches_any
branch starts with "release/", file ends with suffixcompare with starts_with, ends_with
branch does not start with "wip/", file does not end with suffixcompare with not_starts_with, not_ends_with
tested_by is in reviewers, files contains all of gap_filescompare with in, includes_all, matches_any, …
score is at least 3, is at most, is more than, is less thancompare with gte, lte, gt, lt, on numbers only
name is alphabetically before "m", is alphabetically aftercompare with alphabetically_before, alphabetically_after
name is alphabetically on or before "m", is alphabetically on or aftercompare with alphabetically_lte, alphabetically_gte
score is between 1 and 5range
score is not between 1 and 5outside_range
reviewed_at is before deployed_at, is after, is on or after, is on or beforecompare_time with lt, gt, gte, lte

The time phrases can take a written time or number too, like opened is before "2024-01-01T00:00:00Z" or opened is after 100. The field and the written value must both be RFC 3339 times, both be dates like "2026-10-01", or both be numbers, or the check fails as unusable. A date and an RFC 3339 time fail too, even one at midnight, because a date doesn’t say in which time zone it starts. A written value that isn’t a time, a date or a number, like opened is before "yesterday", is written wrong, with "is before" takes a time or a number.

is not ... within and does not equal ... within pass when the two numbers are further apart than the number after within, so x is not 0.1 within 0.19 passes on 0.3 and fails on 0.29. They fail like the positive forms when a side is missing, null or not a number, so the opposite of a check never passes by reading nothing. The same goes for does not start with and does not end with, for is not a <type> and is not a non-empty string, which pass on "" and on any value that isn’t a string, for is not between, and for contains some of and does not contain all of, which need a list like contains all of.

within works out the gap exactly on the decimals, so 0.3 and 0.1 are within 0.2. That’s promised for numbers with at most 15 significant digits. Longer ones, like 0.30000000000000004, are compared on their digits too, but an engine may not tell two of them apart when they differ only past those digits, so don’t rely on it: Ergo with OPA says what ergo.rego does. The only numbers it can’t compare are ones whose exponents, with the number after within, are more than 4096 apart, like 1e5000 and 1. That check fails as unusable.

is empty passes on "" or [] and is not empty on a string or a list with something in it. Anything else that’s there, like a number or an object, fails both as unusable. Their operators only exist for sentences. starts with and ends with compare two strings: anything else, two numbers included, fails as unusable, and a written value after them that isn’t text, like x starts with 1, is written wrong. is alphabetically before and is alphabetically after order two strings in the same way, code point by code point, so every uppercase letter comes first: "Zoe" is alphabetically before "adam". is alphabetically on or before and is alphabetically on or after pass on two equal strings too.

The negative phrases need what the positive ones need, so a field that can’t be read fails them with the same cause. With this policy:

ergo_spec: v0.6
subjects:
  artifact:
    from: artifacts
    identified_by: name
requirements:
  releasable:
    subject: artifact
    checks:
      not_a_snapshot: version does not end with "-SNAPSHOT"
      not_near_limit: size_mb is not 100 within 5
      trusted_license: licenses contains some of "MIT", "Apache-2.0"
      notes_empty: notes is not a non-empty string

and this input:

{"artifacts": [
  {"name": "api", "version": "1.2.0-SNAPSHOT", "size_mb": 101, "licenses": ["GPL-3.0"], "notes": "ready"},
  {"name": "web", "version": "2.0.0", "size_mb": 140, "licenses": ["MIT", "GPL-3.0"], "notes": ""},
  {"name": "cli", "version": 3, "licenses": null, "notes": null}
]}

every check fails on api, with value. web passes all four, notes being "". cli fails each of them for the reason it can’t be read:

checkcli fails asbecause
not_a_snapshotunusableversion is a number
not_near_limitabsentthere is no size_mb
trusted_licensenulllicenses is null
notes_emptynullnotes is null

Text can be joined on the right side with followed by, from written strings and fields, as many pieces as you like: rego.version is tag.version followed by "-dev", or rego.spec is "v" followed by tag.version. It goes after is, equals, is not, does not equal, starts with, ends with, does not start with, does not end with, contains, does not contain and the alphabetical phrases, and the sentence asks what the phrase asks of the joined text. There is no preceded by, because "v" followed by x says it. Every piece must be a string: a written one that isn’t, like followed by 1, is written wrong, and a field that isn’t one fails the check as unusable. With this policy:

ergo_spec: v0.6
subjects:
  release:
    from: releases
    identified_by: tag.version
requirements:
  ready:
    subject: release
    checks:
      dev_version: rego.version is tag.version followed by "-dev"
      spec_is_tagged: rego.spec is "v" followed by tag.version

and this input:

{"releases": [
  {"tag": {"version": "0.4"}, "rego": {"version": "0.4-dev", "spec": "v0.4"}},
  {"tag": {"version": "0.5"}, "rego": {"version": "0.5", "spec": "v0.4"}},
  {"tag": {"version": 5}, "rego": {"version": "5-dev", "spec": "v5"}},
  {"tag": {}, "rego": {"version": "x", "spec": "v"}}
]}

both checks pass for 0.4, fail as value for 0.5, whose rego.version doesn’t end with -dev and whose rego.spec isn’t v0.6, fail as unusable for 5, because tag.version is a number, and fail as absent for the last one, because it has no tag.version. The row of each check shows the left side and then each piece that is a field, in order: for 0.5 the dev_version row has rego.version with "0.5" and tag.version with "0.5", and the joined text isn’t one of them. When a piece can’t be read, the check fails with the cause of the first piece that can’t, left to right, even when the left side is missing too. followed by isn’t run yet after every or some, in a whose or in a from line, where it is written wrong with "followed by" isn't supported yet after every or some, or in a whose. It is run in an assertion and under a for line, where a piece can be the alias. Text that spells the words, like "a followed by b", is one string.

is at least, is at most, is more than and is less than only order numbers. Two strings fail as unusable, because a number held as a string would be ordered as text, and with this input:

{"conditions": [{"metric": "coverage", "value": "9", "errorThreshold": "80"}]}

every conditions.value is at least errorThreshold would pass, since "9" comes after "80" as text. A written value that isn’t a number, like score is at least "3", is written wrong, with "is at least" takes a number.

After is in, is not in, contains, does not contain, contains all of, contains none of, contains some of, does not contain all of, matches and does not match, the thing on the right can be a field too, read like the field after is at least: from the subject, from the name of a for line or from the item of a one-line every or some and a whose. With this policy and this input:

ergo_spec: v0.6
subjects:
  deployment:
    from: deployments
    identified_by: id
requirements:
  prod_deploy:
    subject: deployment
    checks:
      reviewed_by_peer: tested_by is in reviewers
      tickets_covered: tickets contains all of required_tickets
{
  "deployments": [
    {"id": "d1", "tested_by": "ann", "reviewers": ["ann", "bob"], "tickets": ["OPS-1", "OPS-2"], "required_tickets": ["OPS-1"]},
    {"id": "d2", "tested_by": "cy", "reviewers": ["ann", "bob"], "tickets": ["OPS-1"]}
  ]
}

d1 passes both and d2 fails both. Its reviewed_by_peer row fails as value, because cy isn’t in the list, and its tickets_covered row fails as absent, because required_tickets is missing:

{
  "cause": "absent",
  "check": "tickets_covered",
  "inputs": [
    {"locations": ["/deployments/1/tickets"], "name": "tickets", "value": ["OPS-1"]},
    {"locations": ["/deployments/1"], "name": "required_tickets", "value": null}
  ],
  "message": "required_tickets is missing",
  "passed": false,
  "requirement": "prod_deploy",
  "subject": {"id": "d2", "type": "deployment"}
}

They run as compare with in, not_in, includes, excludes, includes_all, excludes_all, includes_some, not_includes_all, matches_any and not_matches_any, and only sentences can use those. The row has the same passed and cause as the sentence with $params.required_tickets holding that field’s value, and the field is in the row’s inputs after the left side. A $params or $input path after these phrases, like $params.regions or $input.sbom.components.name, is in the row’s inputs after the left side too, with null when nothing was read, and stays in the check’s $refs, so a violation lists it once. A field on the right that’s missing or null fails the row as absent or null, whatever the left side is. One that can’t be used fails it as unusable: not a list after is in, is not in, contains all of, contains none of, contains some of, does not contain all of, matches and does not match, an empty list after contains all of, contains none of, contains some of and does not contain all of, and patterns that aren’t all valid regular expressions. Only when the right side can be used does the left side’s own cause count.

The rows of every, some and count of list such a path where they list the right side of is at least: one from $input is listed, and one from $params isn’t, since those rows leave $params to $refs. A for line’s own row lists one from either. With the release {"id": "r1", "approvers": [{"username": "ann"}, {"username": "zed"}]} in releases, "staff": ["ann", "bob"] in the input and {"staff": ["ann", "bob"]} as params, every approvers.username is in $input.staff has the inputs

[
  {"locations": ["/staff"], "name": "$input.staff", "value": ["ann", "bob"]},
  {"locations": ["/releases/0/approvers/0/username", "/releases/0/approvers/1/username"], "name": "approvers[].username", "value": ["ann", "zed"]}
]

and for: every approvers as approver with approver.username is in $params.staff has

[
  {"locations": ["$params:/staff"], "name": "$params.staff", "value": ["ann", "bob"]},
  {"locations": ["/releases/0/approvers/0", "/releases/0/approvers/1"], "name": "approvers[]", "value": [{"username": "ann"}, {"username": "zed"}]}
]

on the row, and the same $params.staff entry after approver.username in the failed assertion of item 2.

A path on the right that goes through a list of objects is walked the way a path after every is, and the phrase compares with the list of what the path ends at in each item. With this policy and this input:

ergo_spec: v0.6
subjects:
  pin:
    from: pins
    identified_by: name
requirements:
  pinned:
    subject: pin
    checks:
      in_the_sbom: name is in $input.sbom.components.name
{
  "sbom": {"components": [{"name": "click"}, {"name": "jinja2"}]},
  "pins": [{"name": "click"}, {"name": "attrs"}]
}

click passes and attrs fails as value:

{
  "cause": "value",
  "check": "in_the_sbom",
  "inputs": [
    {"locations": ["/pins/1/name"], "name": "name", "value": "attrs"},
    {"locations": ["/sbom/components/0/name", "/sbom/components/1/name"], "name": "$input.sbom.components[].name", "value": ["click", "jinja2"]}
  ],
  "message": "name is \"attrs\", and $input.sbom.components[].name is [\"click\", \"jinja2\"]",
  "passed": false,
  "requirement": "pinned",
  "subject": {"id": "attrs", "type": "pin"}
}

The path is in the check’s $refs as $input.sbom.components[].name, with the location of each name, and in the row’s inputs after the left side, as a path from the subject is. It works after every phrase above but contains and does not contain, which take one value. Ergo can’t tell whether the names are complete when one can’t be read, so every row fails: a component with no name fails as absent, with the message item 2 of $input.sbom.components: name is missing, one with a null name as null, something that isn’t an object in the list or after the name as unusable, and an empty list as value, even after is not in, with the message $input.sbom.components is []. A path that meets no list is read as before, so a list that is null or missing fails as null or absent, and text fails as unusable with $input.sbom.components is "x", which isn't an object. A phrase that takes one value, like is or starts with, doesn’t read its right side this way.

contains and does not contain look for one value in a list, so the path after them never walks a list. A list it ends at is one value, so a list of pairs can be searched for a pair. A list it meets on the way fails the row as unusable and names that list. On an input that is a list, a path from $input whose first step is a name, like $input.k, fails as not_an_object with $input is [...], which isn't an object, said once without an item inside a whose; $input[0].k reads the first item, and $input alone is the whole input, looked for as one value. To check a list of values, use contains all of, contains some of or contains none of. With this policy and this input:

ergo_spec: v0.6
subjects:
  repo:
    from: repos
    identified_by: id
requirements:
  pinned:
    subject: repo
    checks:
      has_pair: pins contains wanted
      every_team: teams contains all of $input.owners.team
      one_team: teams contains $input.owners.team
{
  "owners": [{"team": "web"}, {"team": "ops"}],
  "repos": [
    {
      "id": "api",
      "pins": [["click", "8.1"], ["jinja2", "3.1"]],
      "wanted": ["click", "8.1"],
      "teams": ["web", "ops"]
    }
  ]
}

has_pair passes, because ["click", "8.1"] is one of the pins. every_team passes, because teams holds both names. one_team fails as unusable, because $input.owners is a list and contains reads one value:

{
  "cause": "unusable",
  "check": "one_team",
  "inputs": [
    {"locations": ["/repos/0/teams"], "name": "teams", "value": ["web", "ops"]},
    {"locations": ["/owners"], "name": "$input.owners.team", "value": null}
  ],
  "message": "$input.owners is [{\"team\": \"web\"}, {\"team\": \"ops\"}], which isn't an object",
  "passed": false,
  "requirement": "pinned",
  "subject": {"id": "api", "type": "repo"}
}

The thing on the right is a value when it’s in quotes, a number, true, false or null, and a field otherwise. A list of values is separated by , . A string can be in single quotes, which keep backslashes as written, so '^\s*FROM' needs no doubled backslash.

A path is keys joined by ., like build.commit.sha. A key with anything but letters, digits, _ and - goes in double quotes, like labels."app.kubernetes.io/name". $params.regions reads the params and $input.repo the input. artifacts named by $params.name reads the key the param holds, and so does artifacts[$params.name]. named by a bare field reads the key the subject holds, as below. item 2 of commits, first of commits and commits[1] read one item of a list. A position above 9007199254740991, the largest whole number a 64-bit float holds exactly, is written wrong, with 9007199254740992 can't be a list position. A param or input value after named by or in brackets has to hold a string: a number never picks a position, so commits[$params.n] with n holding 1 fails the check as unusable, and so does a boolean, a list or an object. A null one fails it as null and a missing one as absent. A path can’t start with a word the sentences use, like empty, count or true. Put a dot in front, .empty is 1, to read a field with that name. The subject’s own name is the subject itself, so a field called like the subject is written with a dot too.

named by a bare field picks the key from the subject, so a limit can depend on a field like the severity. With the params {"max_days": {"critical": 1, "high": 5}}, this policy:

ergo_spec: v0.6
subjects:
  vuln:
    from: vulns
    identified_by: id
requirements:
  fixed_in_time:
    subject: vuln
    checks:
      within_limit: age_days is less than $params.max_days named by severity

and this input:

{"vulns": [
  {"id": "v-1", "severity": "high", "age_days": 3},
  {"id": "v-2", "severity": "critical", "age_days": 2},
  {"id": "v-3", "severity": "unknown", "age_days": 0}
]}

v-1 passes, v-2 fails as value, and v-3 fails as absent, because the params have no limit for unknown. Each row shows the field that picked the key after the check’s own inputs:

{"check": "within_limit", "subject": {"id": "v-2", "type": "vuln"}, "passed": false, "cause": "value", "message": "age_days is 2, and $params.max_days named by severity is 1",
 "inputs": [
   {"name": "age_days", "value": 2, "locations": ["/vulns/1/age_days"]},
   {"name": "$params.max_days named by severity", "value": 1, "locations": ["$params:/max_days/critical"]},
   {"name": "severity", "value": "critical", "locations": ["/vulns/1/severity"]}
 ]}

A field that’s missing fails the check as absent, a null one as null, and one that isn’t a string, a number included, as unusable. The path before the field is read first, so when it’s missing or can’t be read, like $params.max_days missing from the params, the check fails as that path does, whatever the field holds. A field that can’t be read never lets a check pass, does not exist included: x[0] named by zz does not exist with no x fails as absent when zz is missing, null or a number, and passes only when zz is text. A null before the field doesn’t stop the path. In an every, some, count of, sum of or a for line, a list before the field is walked, so every rows.w named by k is "xy" fails as null when k is null. A null there, like rows being null, fails it as null whatever k holds, and failed_items names rows, as every rows.w.k does. Anywhere else, a list before the field fails the check as unusable, so rows.w named by k is "xy" does when rows is a list. A path that reads inside an item that isn’t an object, like w.k named by zz with w a list, fails as not_an_object whatever zz holds. When nothing is read inside it, like w named by zz with w holding 3, the field decides first, so the check fails as absent, null or unusable when zz is missing, null or not text, and as not_an_object when it is text. An alias that is a list, read by a position, like w[0] named by zz, is read as a list. A name from a for line hides a field with the same name. Inside a whose the field is the item’s, as a bare key is there. In the phrase of a one-line every or some it’s the subject’s, whatever the path starts at, so every xs.v equals $params.m named by k reads the subject’s k for every item, and when the subject has no k the check fails once as absent, with k is missing. A from line and identified_by can’t use it, because they don’t have a subject yet.

In YAML, # starts a comment, so title does not match 'WIP' #, 'DRAFT' loses everything after 'WIP'. YAML also won’t take a plain value that starts with a quote, or that has : in it. Put the whole sentence in single quotes then, doubling any single quote inside: '"$schema" exists'.

A check can hold more than one sentence, joined with and on one line or listed under assert. Both mean the same, and every sentence has to pass. With the input above:

reviewed:
  description: Someone approved and reviewed it before it was deployed
  assert:
    - approved_by is not empty
    - reviewed_at is before deployed_at

The check’s expression is approved_by is not empty and reviewed_at is before deployed_at, and it still has one row per deployment. That row reads every field the sentences read, and says which sentences failed, each with its own cause:

{
  "cause": "value",
  "check": "reviewed",
  "failed_assertions": [
    {"assertion": "approved_by is not empty", "cause": "value"},
    {"assertion": "reviewed_at is before deployed_at", "cause": "value"}
  ],
  "inputs": [
    {"locations": ["/deployments/1/approved_by"], "name": "approved_by", "value": ""},
    {"locations": ["/deployments/1/deployed_at"], "name": "deployed_at", "value": "2026-10-02T10:00:00Z"},
    {"locations": ["/deployments/1/reviewed_at"], "name": "reviewed_at", "value": "2026-10-02T11:00:00Z"}
  ],
  "message": "approved_by is \"\"; reviewed_at is \"2026-10-02T11:00:00Z\", and deployed_at is \"2026-10-02T10:00:00Z\"",
  "passed": false,
  "requirement": "prod_deploy",
  "subject": {"id": "d2", "type": "deployment"}
}

failed_assertions is [] when the check passes. The row’s cause is the first, in the order of causes, among the sentences that failed. To get a row for each sentence, write them as separate checks. A problem with one line of a list starts with its number in the $well_formed row, like line 2: unclosed quote at column 6.

A sentence has no or. When a check can pass in more than one way, write the ways as named options under any_of, each one sentence or a list of them. The check passes when every sentence of one option passes. With this input:

{"components": [
  {"name": "click", "licenses": [{"license": {"id": "BSD-3-Clause"}}]},
  {"name": "prometheus-client"},
  {"name": "leftpad", "licenses": [{"license": {"id": "WTFPL"}}]}
]}

and these params:

{"allowed_licenses": ["BSD-3-Clause", "Apache-2.0"], "known_package_licenses": {"prometheus-client": "Apache-2.0"}}

this check, on a subject component with from: components, takes the licence the SBOM declares, or else the one the params give for the component’s name:

licence_allowed:
  description: The licence is allowed
  any_of:
    declared: some licenses.license.id is in $params.allowed_licenses
    known:
      - licenses does not exist
      - $params.known_package_licenses named by name is in $params.allowed_licenses

click passes on declared and prometheus-client on known. leftpad fails both, and its row is:

{
  "cause": "absent",
  "check": "licence_allowed",
  "inputs": [
    {"locations": ["$params:/allowed_licenses"], "name": "$params.allowed_licenses", "value": ["BSD-3-Clause", "Apache-2.0"]},
    {"locations": ["$params:/known_package_licenses"], "name": "$params.known_package_licenses named by name", "value": null},
    {"locations": ["/components/2/licenses"], "name": "licenses", "value": [{"license": {"id": "WTFPL"}}]},
    {"locations": ["/components/2/licenses/0/license/id"], "name": "licenses[].license.id", "value": ["WTFPL"]},
    {"locations": ["/components/2/name"], "name": "name", "value": "leftpad"}
  ],
  "message": "declared: item 1 of licenses: license.id is \"WTFPL\", and $params.allowed_licenses is [\"BSD-3-Clause\", \"Apache-2.0\"]; known: licenses is [{\"license\": {\"id\": \"WTFPL\"}}], and $params.known_package_licenses named by name is missing",
  "passed": false,
  "requirement": "licences",
  "subject": {"id": "leftpad", "type": "component"}
}

The row reads every field the options read, once each. Its cause is the first, in the order of causes, among the options, and an option’s cause is the first among its sentences. Here known fails as absent, because the params have no licence for leftpad. An option with an exists sentence whose field is missing fails as value instead. The row has no failed_assertions. The check’s expression is one of: declared(some licenses.license.id is in $params.allowed_licenses) | known(licenses does not exist and $params.known_package_licenses named by name is in $params.allowed_licenses), with the options sorted by name.

An any_of takes description and meta, but not for or assert. An any_of that isn’t an object, an empty one, an empty option, or an option that isn’t a sentence or a list of them is written wrong, with invalid any_of, empty any_of, empty option <name> or invalid option <name>. A problem with a sentence starts with its option’s name, and with the line’s number when the option has more than one, like option known: line 2: doesn't parse or option fast: values of mixed types.

every and some check each item of the lists along a path. every pull_requests.commits.signed is true walks the pull requests, then the commits of each, and checks each commit’s signed. It passes when every one does. some passes when at least one does. With this input:

{"releases": [{"id": "r-1", "pull_requests": [
  {"number": 7, "author": "ann", "approvers": [{"username": "bob", "state": "APPROVED"}], "commits": [{"sha": "a1", "signed": true}, {"sha": "a2", "signed": false}]},
  {"number": 8, "author": "bob", "approvers": [{"username": "bob", "state": "COMMENTED"}]}
]}]}

signed: every pull_requests.commits.signed is true gives:

{
  "cause": "absent",
  "check": "signed",
  "failed_items": [
    {"cause": "value", "item": "item 2 of pull_requests[0].commits", "locations": ["/releases/0/pull_requests/0/commits/1/signed"], "path": "pull_requests[0].commits[1].signed", "value": false},
    {"cause": "absent", "item": "pull_requests[1].commits", "locations": ["/releases/0/pull_requests/1"], "path": "pull_requests[1].commits", "value": null}
  ],
  "inputs": [{"locations": ["/releases/0/pull_requests/0/commits/0/signed", "/releases/0/pull_requests/0/commits/1/signed"], "name": "pull_requests[].commits[].signed", "value": [true, false]}],
  "message": "item 1 of pull_requests: item 2 of commits: signed is false; item 2 of pull_requests: commits is missing",
  "passed": false,
  "requirement": "release",
  "subject": {"id": "r-1", "type": "release"}
}
  • inputs names the path with [] after each list walked, and holds the value each item’s path ended at.
  • Each entry in failed_items gives the field that failed as path, and the item it belongs to as item, so a tool can show the item by reading the input there. A list that’s missing, null, not a list or empty is listed in its items’ place. When a whose that compares two fields can’t decide for an item, the entry names the field it couldn’t read, or the left one when it read neither or both, like hi for whose lo is at least hi on {"lo": 1}, with value null. A field that’s null counts as not read, and so does the list of a count of that isn’t a list.
  • distinct after every or some drops an item whose value is the same as an earlier one’s: every distinct commits.author is "ann" on commits by ann, bob and bob fails with one entry in failed_items, for item 2 of commits, whose locations are /commits/1/author and /commits/2/author. The row’s inputs still list all three authors. An item a whose can’t decide, and a value that’s missing, is never dropped. A bare name on the right of the phrase is read from the subject, so it is the same for every copy: with {"lo": 3, "items": [{"b": 5, "lo": 1}, {"b": 5, "lo": 9}]}, every distinct items.b is at least lo passes, with lo read as the subject’s 3. A whose reads the item before copies are dropped. A for line takes it too, like for: every distinct commits.author as author: the alias stands for each different author once, and the entry of a failed one lists every copy. distinct goes right after every, some, count of or sum of, and anywhere else it is written wrong with distinct goes after every, some, count of or sum of. It is a word of the grammar, so a field called distinct is written .distinct, a subject called distinct is written "distinct", and a bare distinct, like distinct is 1, x is distinct or from: every xs whose distinct is 1, is written wrong with distinct is a word of the grammar: write .distinct. Under a for line that doesn’t read, because it doesn’t parse or is written wrong in its own words, like an alias given twice, the lines under it get only the for line’s problem.
  • An empty list fails every as value, because no commits isn’t proof that every commit is signed. Write , if any, after the path to let empty lists pass: every review.incidents, if any, is not empty. A missing list still fails.
  • whose keeps the items that pass its conditions, read on the value the path ends at: some pull_requests.approvers whose state is "APPROVED" exists passes above, with bob’s approval. So count of pull_requests.approvers.username whose state is "APPROVED" is 1 reads state on each username, and fails as not_an_object with item 1 of pull_requests: item 1 of approvers: username is "bob", which isn't an object; item 2 of pull_requests: item 1 of approvers: username is "bob", which isn't an object. To keep items by one field and check another, put them on a for line, below. An item a condition can’t decide, because the field it reads is missing, fails the check rather than being left out.
  • In a one-line every or some, a field on the right of the phrase is the subject’s, so some approvers.timestamp is after last_commit compares each approver’s timestamp with the subject’s last_commit, and so is a name written with a dot. When that field is missing, null, or something the phrase can’t use, like text after is at least, the message says so once, like last_commit is missing, because no approver could pass. The same goes for $params, $input, an alias, a part or a parent there, and for a list they walk: with the params {"envs": [{"name": "a"}, null]}, every xs.n is in $params.envs.name fails as unusable with item 2 of $params.envs is null, which isn't an object, and names no item of xs. When the item’s field and the field on the right have the same name, the message writes the item’s path in full: item 2 of conditions: conditions.type is "b", and type is "a". To compare two fields of the same item, use a for line, below. A for alias is read on the right of a whose too, count of and sum of included, so with for: every groups as g, count of rows whose n is count of g is 1 counts the group g, and .g reads each row’s own g. A child subject’s parent works the same way: with the parent fam, count of rows whose n is count of fam.z is 1 counts the parent’s z, count of rows whose count of fam.z is 1 is 3 counts each row’s own fam.z, and .fam.z on the right reads the row’s. A parent whose z is missing, null or not a list fails the check as absent, null or unusable.
  • every, some, count of and sum of paths can be ten steps long, wherever they stand, on the right of a phrase too. A for alias and a position like first of each count as a step, and $input and $params don’t. A walk that starts at a part counts the part’s own path instead of its alias, so with has: {"x": "k1.k2.k3.k4.k5.k6.k7.k8.k9"}, every x.z is 1 is ten steps, and a part of ten names makes it eleven. A part read without a walk, like x.z is 1, has no limit. An item picked on a for line, like first of reviews as review, takes ten names, not counting its position (sentence.walk.steps).
  • contains and the other list phrases can’t follow every or some, because the last list is walked too, so every pull_requests.labels does not contain "hotfix" would ask each label. It’s written wrong, and the message says to put the walk on a for line.

A for line names the items of one or two lists, so the sentences under assert can read an item and the subject together. Each item is every or some, a path, an optional whose, and as an alias, or picks one item by its position, as below. The second item can walk a list inside the first, by its alias.

An alias is a short word that stands for something longer: as pr makes pr an alias for each pull request in turn. With the input above:

peer_approved:
  description: Someone other than the author approved every pull request
  for: every pull_requests as pr, some pr.approvers as approver
  assert:
    - approver.state is "APPROVED"
    - approver.username does not equal pr.author

reads “for every pull request, some approver approved it and isn’t its author”. Both sentences have to hold for the same approver. The check’s expression is for every pull_requests as pr, some pr.approvers as approver: approver.state is "APPROVED" and approver.username does not equal pr.author, and it gives:

{
  "cause": "value",
  "check": "peer_approved",
  "failed_items": [
    {
      "cause": "value",
      "failed_assertions": [
        {"assertion": "approver.state is \"APPROVED\"", "cause": "value", "items": [
          {"cause": "value", "inputs": [{"locations": ["/releases/0/pull_requests/1/approvers/0/state"], "name": "approver.state", "value": "COMMENTED"}], "item": "item 1 of pr.approvers"}
        ]},
        {"assertion": "approver.username does not equal pr.author", "cause": "value", "items": [
          {"cause": "value", "inputs": [
            {"locations": ["/releases/0/pull_requests/1/approvers/0/username"], "name": "approver.username", "value": "bob"},
            {"locations": ["/releases/0/pull_requests/1/author"], "name": "pr.author", "value": "bob"}
          ], "item": "item 1 of pr.approvers"}
        ]}
      ],
      "item": "item 2 of pull_requests",
      "locations": ["/releases/0/pull_requests/1"],
      "path": "item 2 of pull_requests",
      "value": {"approvers": [{"state": "COMMENTED", "username": "bob"}], "author": "bob", "number": 8}
    }
  ],
  "inputs": [
    {"locations": ["/releases/0/pull_requests/0/approvers/0", "/releases/0/pull_requests/1/approvers/0"], "name": "pr.approvers[]", "value": [{"state": "APPROVED", "username": "bob"}, {"state": "COMMENTED", "username": "bob"}]},
    {"locations": ["/releases/0/pull_requests/0", "/releases/0/pull_requests/1"], "name": "pull_requests[]", "value": [
      {"approvers": [{"state": "APPROVED", "username": "bob"}], "author": "ann", "commits": [{"sha": "a1", "signed": true}, {"sha": "a2", "signed": false}], "number": 7},
      {"approvers": [{"state": "COMMENTED", "username": "bob"}], "author": "bob", "number": 8}
    ]}
  ],
  "message": "item 2 of pull_requests: item 1 of approvers: approver.state is \"COMMENTED\", and approver.username is \"bob\", and pr.author is \"bob\"",
  "passed": false,
  "requirement": "release",
  "subject": {"id": "r-1", "type": "release"}
}
  • failed_items lists the items of the first list that failed, each with what its alias stands for as its value. Each one has failed_assertions: the sentences that failed for it, each with its cause. With two items, a sentence that failed for several items of the second list has the first of their causes in the order of causes. Pull request 8 failed because its one approver commented, and is its author.

  • With two items, a failed sentence that reads the second item’s alias also has items: the items of the second list it failed for, in list order, each with the cause it failed with. Here item 1 of pr.approvers is pull request 8’s first approver. With for: some approvers as approver, every commits as commit, an approver who approved before the last commit fails approver.timestamp is after commit.timestamp with "items": [{"cause": "value", "inputs": [...], "item": "item 2 of commits"}], so you can see which commit came later. Each item has its own cause: a commit with no timestamp would be listed there with "cause": "absent". A sentence that doesn’t read commit, like approver.username does not equal author, has no items, because it fails the same way for every commit.

  • Each failed sentence has inputs, the values it compared, so a tool can show “1792655359 is not after 1792656245” without reading the sentence. They are the entries the sentence would have in the inputs of a row of its own, with the names as the sentence writes them and where each value was read in locations, see where a value was read. An entry of items has the inputs for that pair of items. A sentence with no items has them on the sentence, as the sentence read them on the item of the first list it belongs to. They keep the order the sentence reads them, its left side and then its right side, and a value it reads twice is listed once. Above, the second sentence read approver.username and pr.author, both for pull request 8. Every failed sentence has them, however many items fail, so a report where many items fail is longer. A read that stops early, like a commit with no timestamp, shows "value": null and the location where it stopped, and a count of on an empty list gives the count 0, the list [] and no locations. With this input:

    {"pr": {"author": "ann",
      "approvers": [{"state": "APPROVED", "timestamp": 1792655359, "username": "bob"}, {"state": "APPROVED", "timestamp": 1792655761, "username": "cat"}],
      "commits": [{"sha": "a1", "timestamp": 1792590878}, {"sha": "a2", "timestamp": 1792656245}]}}
    

    and for: some approvers as approver, every commits as commit with approver.timestamp is after commit.timestamp, the first entry of failed_items is:

    {"cause": "value", "failed_assertions": [
      {"assertion": "approver.timestamp is after commit.timestamp", "cause": "value", "items": [
        {"cause": "value", "inputs": [
          {"locations": ["/pr/approvers/0/timestamp"], "name": "approver.timestamp", "value": 1792655359},
          {"locations": ["/pr/commits/1/timestamp"], "name": "commit.timestamp", "value": 1792656245}
        ], "item": "item 2 of commits"}
      ]}
    ], "item": "item 1 of approvers", "locations": ["/pr/approvers/0"], "path": "item 1 of approvers", "value": {"state": "APPROVED", "timestamp": 1792655359, "username": "bob"}}
    

    A sentence that works out a number, like count of commit.files is at least 1, has the number and then the list it counted, with the locations of that list. A part’s input has its part. Only the sentences of a for line have them: the failed_assertions of a check with several sentences and no for line have none, because its row’s inputs already hold the values.

  • failed_assertions is [] when no sentence was checked, like when whose leaves no approver, or a field a whose reads is missing.

  • When the second item failed before any sentence was checked, the item of the first list has its own failed_items, saying where. With for: some approvers as approver, every commits whose author does not equal approver.username as commit and a commit with no author, {"t": 2}, the approver’s entry has "failed_assertions": [] and:

    "failed_items": [{"cause": "absent", "item": "item 2 of commits", "locations": ["/releases/0/commits/1"], "path": "item 2 of commits", "value": {"t": 2}}]
    

    What fails the same way for every approver is listed once in the row’s failed_items instead, in place of the approvers that failed only because of it. That’s a missing commits list, {"cause": "absent", "failed_assertions": [], "item": "commits", "locations": ["/releases/0"], "path": "commits", "value": null}, or a commit that whose kind is "code" can’t decide, because that whose doesn’t read approver. An approver that’s still listed has the cause of what’s left under it. A list inside the first, like pr.approvers, is listed under each pull request, because each has its own.

  • inputs holds each list the for line walked, with its items, and every field of the subject or of a parent the sentences read, like t.x. A second list that starts at the first item’s alias, like z.subs in every items whose k is 1 as z, every z.subs as y, shows only the items under the first items that whose kept. The first list still shows every item, the ones whose left out too. A field read through a for alias, like pr.author, isn’t listed on its own.

  • In assert, a bare first key is the subject’s field, and a name reaches its item. Inside an item’s own whose, a bare key is that item’s field. A name hides a field with the same name, the subject’s or an item’s, except right after whose: whose env is "prod" always reads the item’s env, and so does whose count of env is 2. Write a dot in front to read the field a name hides: .env.

  • Each list fails as a one-line every or some does: empty, missing, null or not a list, and , if any, lets an empty one pass.

  • A name that doesn’t depend on the item is read once, before any item: a field of the subject, a part, $params or $input, with or without a path after it, and an alias from an earlier line. When a compare or is in phrase reads it as a value, like z in m.v is at least z, $params.q in m.v is in $params.q or z in z is 1, when count of or sum of reads it, like count of z, and when it is a named by key, like z in m.t named by z, a problem with it fails the check even when the list is empty, and the message names it once, with no item in front, whatever its cause. With z holding "s", z is 1 says z is "s", which isn't a number and count of z is 1 says z is "s", which isn't a list or an object, with z holding {"a": 1}, count of z is 1 says z is {"a": 1}, which isn't a list, and with z holding 3, m.t named by z is 1 says z is 3, which isn't text. Each name is said once, and a sentence that reads two of them isn’t said again after them: count of zb is count of za, with zb holding "s" and za holding 3, says za is 3, which isn't a list or an object; zb is "s", which isn't a list or an object. The names of one sentence come worst cause first and then by name: za is at least zb with za null and zb holding "s" says zb is "s", which isn't a number; za is null, because unusable is worse than null, and the two count of names above tie, so za comes first. A value of the right type that doesn’t match, like 3 in z is 1, is no problem with reading it. A name read any other way, like every z or z exists, is read for each item, so on an empty list the check says only xs is []. A name that depends on the item, the line’s own alias or a named by key read from the item, keeps the item in front, because it can differ for each item. With the subject pr from prs, the params {"q": "s"} and this input:

    {"prs": [{"id": "p0", "z": "s", "xs": []}]}
    

    for: every xs as m with assert: m.v is at least $params.q fails as unusable with $params.q is "s", which isn't a number; xs is []: the name first, then the empty list. Written every xs, if any, as m, it says only $params.q is "s", which isn't a number. With xs holding [{"v": 1}, {"v": 2}] it says the same once, and failed_items lists both items, each unusable. When an item also fails on its own, its own problems follow the names, without them: for: every xs as m with the sentences m.v is 2 and m.v is at least z, and z missing, on [{"v": 1}, {"v": 3}] says z is missing; item 1 of xs: m.v is 1; item 2 of xs: m.v is 3, and the cause is the worst of them, absent. With z holding "s" and xs holding [{"v": null}], it says z is "s", which isn't a number; item 1 of xs: m.v is null and fails as unusable, the worse of unusable and null. A name that is only $params or $input counts too: for: every xs as m with $params is 3 and the params null says $params is missing; xs is [] on []. A root in a whose on the for line doesn’t hide what an item says on its own, whichever clause comes first: every xs whose k is in pr.z and whose v is 1 as m with pr.z holding "s" says pr.z is "s", which isn't a list; item 2 of xs: v is "x", which isn't a number. A list on a later line that doesn’t depend on the item is said before the items’ problems. A part that holds a list isn’t walked: with has: {pl: c} and the input {"prs": [{"id": "p0", "c": [{"z": 1}], "xs": [{"v": 1}]}]}, m.v is in pl.z fails as unusable with pl is [{"z": 1}], which isn't an object, as pl.z is 1 does, and so does count of xs whose v is in pl.z is 0. With params that aren’t an object, like 3, for: every xs as m with m.v is at least 1 on {"prs": [{"id": "p0", "xs": ["s", {"v": "x"}]}]} still fails as unusable, where the item alone would make it not_an_object, and says item 1 of xs is "s", which isn't an object; item 2 of xs: v is "x", which isn't a number. Only ambiguous ranks above unusable there: for: every xs as p, first of xs whose k is p.k sorted by v as m with m.v is 1, params 3 and two items that tie on v says item 1 of xs: item 1 and item 2 of xs tie on v; item 2 of xs: item 1 and item 2 of xs tie on v and fails as ambiguous, and with m.v is at least $params.n it says $params is 3, which isn't an object; before the tie.

  • Two items at most.

  • named by an alias from the for line reads the key its item holds. With for: every personas_dispatched as persona, persona_blob_shas named by persona exists reads persona_blob_shas.security, then persona_blob_shas.style, and so on. An item that’s null fails as null, and any other item that isn’t a string, a number included, as unusable, since it can’t be a key. m.k named by m.k is 1 on {"prs": [{"id": "p0", "xs": [null, {"k": "a"}]}]} says item 1 of xs: m is null; item 2 of xs: m is {"k": "a"}, which isn't text. The row’s inputs show persona_blob_shas itself.

With for: some approvals.approver as approver, where a commit also has an approver field:

sentencecompares
every commits.author does not equal approvereach commit’s author with the name approver
every commits.author does not equal .approvereach commit’s author with its own approver
.approver equals approverthe subject’s approver with the name

A for item can start with a position instead of every or some, and then its alias stands for that one item: first of commits, item 2 of commits, last of commits, or item 2 from last of commits. Positions count from 1. An index in brackets doesn’t pick, so commits[0] as c is written wrong, with for: an item starts with "every", "some" or a position, like "last of": write first of commits as c. Brackets after every or some, like every commits[0].files as f, are fine. Add sorted by and a field of each item to count in the order of that field rather than the order of the list. With this input:

{"pull_requests": [{"number": 7,
  "approvers": [{"username": "ada"}],
  "commits": [
    {"sha": "a1", "author_username": "ada",   "timestamp": 1760000200},
    {"sha": "a2", "author_username": "grace", "timestamp": 1760000100}
  ]}]}
not_last_author:
  description: The approver didn't push the last commit
  for: last of commits sorted by timestamp as last_commit, some approvers as approver
  assert: approver.username does not equal last_commit.author_username

picks a1, the commit with the latest timestamp, wherever it is in the list, and gives:

{
  "cause": "value",
  "check": "not_last_author",
  "failed_items": [
    {
      "cause": "value",
      "failed_assertions": [
        {"assertion": "approver.username does not equal last_commit.author_username", "cause": "value", "items": [
          {"cause": "value", "inputs": [
            {"locations": ["/pull_requests/0/approvers/0/username"], "name": "approver.username", "value": "ada"},
            {"locations": ["/pull_requests/0/commits/0/author_username"], "name": "last_commit.author_username", "value": "ada"}
          ], "item": "item 1 of approvers"}
        ]}
      ],
      "item": "item 1 of commits",
      "locations": ["/pull_requests/0/commits/0"],
      "path": "item 1 of commits",
      "value": {"author_username": "ada", "sha": "a1", "timestamp": 1760000200}
    }
  ],
  "inputs": [
    {"locations": ["/pull_requests/0/approvers/0"], "name": "approvers[]", "value": [{"username": "ada"}]},
    {"locations": ["/pull_requests/0/commits/0"], "name": "item 1 of commits", "value": {"author_username": "ada", "sha": "a1", "timestamp": 1760000200}}
  ],
  "message": "item 1 of commits: item 1 of approvers: approver.username is \"ada\", and last_commit.author_username is \"ada\"",
  "passed": false,
  "requirement": "review",
  "subject": {"id": 7, "type": "pull_request"}
}
  • The expression says how the item was picked, for last of commits sorted by timestamp as last_commit, โ€ฆ, and inputs says which item that was in the input, item 1 of commits.
  • first of commits sorted by timestamp is the earliest, last of the latest, and item 2 from last of the second latest.
  • A whose goes before sorted by, and keeps the items to sort: last of approvers whose state is "APPROVED" sorted by timestamp as approval is the latest approval. An item the whose can’t decide fails the check with its cause.
  • sorted by sorts numbers, and RFC 3339 times by the moment they name, so 2026-10-02T01:00:00+02:00 comes before 2026-10-02T00:00:00Z. Dates like 2026-10-02 are sorted by day. An item whose field is missing, null or anything else fails the check as absent, null or unusable, without picking, since the order can’t be known. When the fields mix numbers, times and dates, the numbers are kept and the rest fail as unusable, and with times and dates the dates fail.
  • sorted by name alphabetically sorts strings instead, code point by code point, so "Zoe" comes before "adam". Every field has to be a string. There’s no word for the reverse order: last of and item 2 from last of count from the end.
  • When another item has the same value as the one picked, nothing is picked and the check fails as ambiguous, listing the items that tie. With both commits at 1760000200 above, failed_items holds item 1 of commits and item 2 of commits, each with the timestamp as value. A tie elsewhere in the list doesn’t matter.
  • A position past the end of the list, or an empty list, fails as absent.
  • sorted by reads a field of the item, so it can’t start with $params or $input. A field with the same name as a name from the for line needs a dot in front, sorted by .c. It can’t go after every or some, and , if any, can’t go with a position.
  • A picked second item can read the first item’s name in its whose, and is picked again for each item of the first list: for: every xs as p, first of ys whose k is p.k as q gives each p the first y with the same k. inputs lists each item picked, like item 2 of ys. A first item for which nothing is picked isn’t listed in failed_items, and a ys that can’t be walked is listed once.
  • A field of the picked item is read through its name, last_commit.author_username. In a sentence, last of commits.author_username would be the last of a list at commits.author_username.

count of, sum of and plus work out a number to compare. With this input:

{"reviews": [{"id": "pr-7", "personas_ran": ["security"], "findings": [{"source": "claude"}, {"source": "gpt"}, {"source": "claude"}], "claude_found": 2, "stages": [{"kind": "model", "usd": 0.5}, {"kind": "model", "usd": 0.25}, {"kind": "tool", "usd": 0}], "total_usd": 0.76}]}

and these checks on a review subject read from reviews:

some_persona: count of personas_ran is at least 1
claude_findings: count of findings whose source is "claude" equals claude_found
costs_add_up: sum of stages.usd that is more than 0 equals total_usd within 0.01
all_findings: count of findings equals claude_found plus 1

all four pass. Each row shows the number Ergo worked out, named after the words that asked for it as the report prints them, then the list it walked:

checkinputs
some_personacount of personas_ran = 1, personas_ran[] = ["security"]
claude_findingscount of findings whose source is "claude" = 2, findings[] = [...], claude_found = 2
costs_add_upsum of stages.usd that is more than 0 = 0.75, stages[].usd = [0.5, 0.25, 0], total_usd = 0.76
all_findingscount of findings = 3, findings[] = [...], claude_found plus 1 = 3, claude_found = 2

Each entry also has locations. A number Ergo worked out lists where what it came from was read: count of findings = 3 lists the three findings it walked, sum of stages.usd that is more than 0 = 0.75 the usd of each of the three stages, and claude_found plus 1 = 3 lists /reviews/0/claude_found.

  • count of walks every list along its path, as every does, so count of pull_requests.commits counts the commits of every pull request. An empty list adds nothing, so count of approvers is 0 when there are none.
  • A list that’s missing, null or not a list fails the check as absent, null or unusable, and so does a number to add that’s missing, null or isn’t a number.
  • An item that whose can’t decide about fails the check with the whose’s own cause, as with every: count of items whose k is "a" on an item with k: null fails as null. A sum of adds numbers, which have no fields, so a whose that reads a name of the value it adds is written wrong: sum of items.x whose k is "a" is 1 says whose k: sum of adds up numbers, which have no fields: to keep numbers by their value, write that instead of whose. Write that to keep numbers by their value, like sum of items.x that is at least 1 is 3. The right side of that can’t read a field of the number either, so with the subject pr, sum of items.x that equals k is 1 says that k: sum of adds up numbers, which have no fields, so k can't be read from one: to read the subject's k, write pr.k. A written value, $params, an alias, a part, a parent or pr.k there is fine. A named by key read against the number is written wrong the same way: sum of items.x that equals $params.m named by k is 1 names k. A name a whose reads from a root, like $params.q in count of items whose k equals $params.q is 0, is read once, up front, so when it’s missing the check fails as absent with $params.q is missing, once, even on an empty list. The same goes for one that isn’t a list where the phrase looks in a list, so count of xs whose v is in pr.z is 0 with pr.z "s" fails as unusable with pr.z is "s", which isn't a list when xs is []. When more than one such name fails, each is named, whatever its cause, in the order the sentence reads them: count of xs whose aa equals $params.q and whose b equals pr.z is 1 with neither there says $params.q is missing; pr.z is missing, and with $params.q null instead it says $params.q is null; pr.z is missing. The same goes for a name on the right of the phrase after every or some, and for a whose on a for item. In a count of or sum of, a $params or $input name that is missing or null comes first, sorted, and the others follow in the order the sentence reads them: count of xs whose w is in pr.l and whose v is in $params.l is 0 with neither there says $params.l is missing; pr.l is missing. A name of the wrong type for any phrase fails the row before any item, on an empty list too, with no item in front: pr.z is [1], which isn't a number for count of xs whose v is at least pr.z is 0, and the same for starts with and is before. A name in a sentence under a for line is named once, with no item.
  • whose goes after the last count of or sum of, and only on the left: claude_found plus count of findings whose source is "gpt" is at least 3.
  • A row shows each input once, so count of approvals plus count of approvals is 4 lists count of approvals and approvals[] one time each.
  • distinct after count of or sum of takes each value once: on commits by ann, ann and bob, count of distinct commits.author is at least 2 passes, and its row lists count of distinct commits.author with 2 and commits[].author with all three names. sum of distinct stages.usd adds each different amount once, so two stages that both cost 1.5 add 1.5. With a whose, only the items it keeps are counted. A null author counts as one more value, as it does for count of. To leave it out, keep only the commits whose author exists: on commits by ann, null, bob and ann, count of distinct commits.author is 3, and count of distinct commits.author that exists is 2.
  • Numbers are added as decimals, so 0.1 plus 0.2 is 0.3. Take every number added, a count or a sum included, and the result, and let p be the most decimal places any of them has, counted on the value: 1.50 has 1, 1e2 has 0 and 1e-7 has 7. p has to be at most 15, and each of them times 10^p has to be a whole number below 10^15, or the check fails as unusable and the number is null. So the sum has to fit in 15 digits at one scale. That’s stricter than 15 significant digits for big round numbers, because 1e20 plus 1e20 fails too. Every runtime can decide it exactly, so every runtime gives the same answer.

With x plus y is at most 1 on {"x": 0.1, "y": 0.2}, the row is satisfied:

{"check": "sum", "passed": true, "cause": "satisfied", "inputs": [
  {"name": "x plus y", "value": 0.3, "locations": ["/things/0/x", "/things/0/y"]},
  {"name": "x", "value": 0.1, "locations": ["/things/0/x"]},
  {"name": "y", "value": 0.2, "locations": ["/things/0/y"]}
]}

With {"x": 0.123456789012345, "y": 100}, the sum needs 18 digits at one scale, so the row fails as unusable and x plus y is null:

{"check": "sum", "passed": false, "cause": "unusable", "message": "x plus y needs more than 15 digits", "inputs": [
  {"name": "x plus y", "value": null, "locations": ["/things/0/x", "/things/0/y"]},
  {"name": "x", "value": 0.123456789012345, "locations": ["/things/0/x"]},
  {"name": "y", "value": 100, "locations": ["/things/0/y"]}
]}

sum of xs is at most 1 on {"xs": [1e20]} fails as unusable too, with sum of xs as null and xs[] as [100000000000000000000]. A sum of [0.1, 0.2, 100] is 100.3, and 1.50 plus 1 is 2.5, trailing zeros or not.

  • A count goes with is, equals, is not, does not equal, is at least, is at most, is more than, is less than, is between, is not between and within.

seconds, minutes, hours, days and weeks followed by elapsed from and two paths, like days elapsed from first_seen_ts to now_ts, work out the time between two fields. Take a requirement on vulnerabilities, with the policy’s params {"max_days": {"high": 7, "low": 30}} and this input:

{"vulnerabilities": [
  {"id": "CVE-1", "severity": "high", "first_seen_ts": "2026-10-01T00:00:00Z", "now_ts": "2026-10-04T12:00:00Z"},
  {"id": "CVE-2", "severity": "low", "first_seen_ts": "2026-08-01T00:00:00Z", "now_ts": "2026-10-04T12:00:00Z"}
]}
fixed_in_time: days elapsed from first_seen_ts to now_ts is less than $params.max_days named by severity

CVE-1 passes, with 3.5 days against a limit of 7. CVE-2 fails as value, with 64.5 days against 30, and its row’s inputs are:

[
  {"name": "days elapsed from first_seen_ts to now_ts", "value": 64.5, "locations": ["/vulnerabilities/1/first_seen_ts", "/vulnerabilities/1/now_ts"]},
  {"name": "first_seen_ts", "value": "2026-08-01T00:00:00Z", "locations": ["/vulnerabilities/1/first_seen_ts"]},
  {"name": "now_ts", "value": "2026-10-04T12:00:00Z", "locations": ["/vulnerabilities/1/now_ts"]},
  {"name": "$params.max_days named by severity", "value": 30, "locations": ["$params:/max_days/low"]},
  {"name": "severity", "value": "low", "locations": ["/vulnerabilities/1/severity"]}
]
  • A unit is a fixed length: a second, 60 seconds, 3600, 86400 and 604800. The result is rounded down to 6 decimal places, so 36 hours is 1.5 days and one second is 0.000011 days. That way every runtime gives the same number. There’s no months or years, because they have no fixed length.
  • Both fields must be RFC 3339 times, like 2026-10-01T00:00:00Z (a capital T and Z, a real date, a year from 1678 to 2261), both dates, like 2026-10-01, which are whole days apart, or both numbers, which are read as seconds, to the nearest microsecond. A number must be between -9000000000 and 9000000000, about the years 1684 to 2255, so a time in milliseconds fails as unusable instead of being read as seconds. A missing field fails the check as absent, a null one as null, and anything else, including one time and one number, as unusable.
  • When the second time is before the first, the check fails as value, and the number in its first input is null.
  • It goes where a count goes: at the start of a sentence with the phrases a count goes with, as a term of plus and on the right side of a phrase, where is prints as equals, as it does before a count. It also goes in the whose of a count of, a sum of or an every, where an item whose end is before its start is left out. every, some and a from line are written wrong with isn't supported yet. It works in each line under assert, and under a for line, where a path can start at a name, like days elapsed from pr.opened_ts to pr.merged_ts is at most 2.
  • seconds, minutes, hours, days, weeks and to are words of the grammar, so a field with one of those names is written .days.

A sentence that can’t be read is written wrong, and the $well_formed row says why, with the column when it can:

{"name": "checks.prod", "value": ["unclosed quote at column 16"], "locations": []}

for prod: environment is "prod. x is not null is written wrong too, because it could only fail: write x exists. So is a value that isn’t a number before within, like x is "a" within 1. A sentence that compares a path with itself, like x is at least x, is written wrong too, because it could only pass or only fail. That happens most often in a whose, where a bare name is the item’s: with the subject pr, count of conditions whose type equals type is 1 says type is compared with itself, because in a whose it's read from the item: to read the subject's type, write pr.type. When the subject’s name has to be written in double quotes, like "my pr", or there’s no subject, the message stops after read from the item. After every or some the right side is the subject’s, so every conditions.type equals type compares each condition’s type with the subject’s. An object with op, the old way of writing a check, has no assert and is written wrong.

Ergo doesn’t run every sentence the spec describes yet. These are written wrong for now, with isn't supported yet in the $well_formed row: last of and item <n> from last of anywhere but at the start of a for item, a sum of or a plus in the whose of a count of or a sum of, a plus in the phrase of an every or some, and an elapsed time after every or some or in a from line. So last_signed: last of commits.signed is true gives:

{"name": "checks.last_signed", "value": ["last of isn't supported yet here: pick the item on a for line, like \"for: last of commits as commit\""], "locations": []}

Pick the item on a for line instead, as the message says.

In a from line or an identified_by, named by a bare word is written wrong too, with unknown name <word>, because there’s no subject to read it from yet.

Any other $<word>, like $foo, is written wrong with unknown name $foo, in a sentence, a for line, a from line or the identified_by of a part. A subject’s identified_by is only a path, so $foo there says identified_by is a path, like "id" or "build.number". The line is still read by its grammar first, so a syntax error in it is the problem you get, with doesn't parse or its own message, and an unknown name never hides it.

Where a name resolves

The sentence decides what each name reads, never the data. A relative clause starts at whose or that and ends where its condition ends, and inside one a name resolves against the focus, the thing the clause is about, like each approver. Everywhere else a name resolves from its root, which is the subject unless the name is $input, $params, a for name, a part, a parent subject’s name or the subject’s own name. The name right after whose is always the item’s field. A dot in front of a name only gets round a clash with a word of the grammar or another name, and reads where the bare name would. spec/syntax.md has the rules. With a pull request pr as the subject:

The words that put something in focus:

wordexamplein focus
every, someevery approvers.username โ€ฆeach username
count of, sum ofcount of approvers โ€ฆeach approver
distinct after one of thoseevery distinct xs.b โ€ฆeach different b
a for item (every, some, a position)for: every approvers as approvereach approver, named approver
a from linefrom: deployments whose โ€ฆeach deployment (each candidate subject)
one of these inside a relative clausexs whose count of ys.k is 1each k of each x’s ys, inside the focus

Relative clauses, where names resolve against the focus:

wordexampleresolves
whoseapprovers whose state is "APPROVED"state: each approver’s
its right sidedeployments whose finished_at is after started_atstarted_at: each deployment’s
thatws that starts with "b"each w itself
and whose, and thatโ€ฆ and whose username โ€ฆa new relative clause, same focus
named by <key> inside onexs whose t named by k is 1k: each x’s
sorted by (not a relative clause)last of commits sorted by timestamptimestamp: each commit’s

Everywhere else, names resolve from their root:

placeexampleresolves
the start of an assertionauthor is "ann"the subject’s author
the phrase after every, count of โ€ฆevery approvers.username does not equal authorauthor: the subject’s
and followed by a path or every โ€ฆโ€ฆ and author is "ann"a new assertion from the root
lines under a forapprover.username is in $params.staffapprover: the alias; bare names: the subject’s
plus terms, followed by pieces, elapsed from โ€ฆ to โ€ฆx is "v" followed by tagwherever they stand: from the root outside a relative clause, against the focus inside one

Names that choose their own root, anywhere but right after whose or sorted by:

nameset up byexampleroot
$input, $paramsnothing: always thereauthor is in $params.staffthe input, the params
a for aliasfor: every approvers as approverapprover.username is in $params.staffeach approver of the subject
a partsubject pr with has: {coverage: attestations.coverage}coverage.pct is at least 80the subject’s attestations.coverage, so it reads attestations.coverage.pct
a parent subject’s namesubject pr with from: every artifact.pull_requests, so artifact is its parentbase equals artifact.branchthe artifact this pull request was read from
the subject’s namethe requirement’s subject: prpr is an object, and approvers whose username does not equal pr.authorthe subject itself. pr.author only inside a relative clause
a leading dota field named like a grammar word or the subject, like empty or pr.empty is true, .pr is "x"no new root: escapes the clash and resolves where the bare name would

The subject itself

A list of plain values, like ids, regions or tags, can be a subject, and the subject’s name is how a check says something about the value. With these params:

{"allowed_regions": ["eu-west-1"]}

this input:

{"deployment": {"id": "d-17", "regions": ["eu-west-1", "us-east-1"]}}

and this policy:

ergo_spec: v0.6
subjects:
  region:
    from: every deployment.regions
requirements:
  allowed:
    subject: region
    checks:
      allowed: region is one of $params.allowed_regions

each region is a subject, with the region as its id, and the one that isn’t allowed fails. The check’s expression is region is in $params.allowed_regions, and the rows are:

{"check": "allowed", "subject": {"id": "eu-west-1", "type": "region"}, "passed": true, "cause": "satisfied",
 "inputs": [{"name": "region", "value": "eu-west-1", "locations": ["/deployment/regions/0"]}, {"name": "$params.allowed_regions", "value": ["eu-west-1"], "locations": ["$params:/allowed_regions"]}], "requirement": "allowed"}
{"check": "allowed", "subject": {"id": "us-east-1", "type": "region"}, "passed": false, "cause": "value", "message": "region is \"us-east-1\", and $params.allowed_regions is [\"eu-west-1\"]",
 "inputs": [{"name": "region", "value": "us-east-1", "locations": ["/deployment/regions/1"]}, {"name": "$params.allowed_regions", "value": ["eu-west-1"], "locations": ["$params:/allowed_regions"]}], "requirement": "allowed"}

The subject’s name, as the requirement’s subject gives it, means the subject itself, whatever it holds. region is an object passes for an object, and a subject that is null or the wrong type for the phrase fails the way the same phrase fails on a field holding that value: region is a string on null fails as null, on 3 as value, and region is at least 2 on "a" as unusable. Two regions with the same name still fail $unique_ids, because ids are the values.

The name is also the path after every, some, count of and sum of, so a subject that is a list can be checked item by item. With the subject tags, from: every releases.tags and the input {"releases": {"tags": [["a", "b"], [1, "x"]]}}, every tags is a string passes the first tag list and fails the second, with the row:

{"check": "c", "subject": {"id": [1, "x"], "type": "tags"}, "passed": false, "cause": "value", "message": "item 1 of tags is 1",
 "failed_items": [{"cause": "value", "item": "item 1 of tags", "path": "item 1 of tags", "value": 1, "locations": ["/releases/tags/1/0"]}],
 "inputs": [{"name": "tags[]", "value": [1, "x"], "locations": ["/releases/tags/1/0", "/releases/tags/1/1"]}], "requirement": "r"}

A position is written in words, as for a field. With the input {"releases": {"tags": [["a", "b"], [1, "x"], []]}} and the same from, first of tags is "a" gives:

{"check": "c", "subject": {"id": ["a", "b"], "type": "tags"}, "passed": true, "cause": "satisfied",
 "inputs": [{"name": "item 1 of tags", "value": "a", "locations": ["/releases/tags/0/0"]}], "requirement": "r"}
{"check": "c", "subject": {"id": [1, "x"], "type": "tags"}, "passed": false, "cause": "unusable", "message": "item 1 of tags is 1, which isn't text",
 "inputs": [{"name": "item 1 of tags", "value": 1, "locations": ["/releases/tags/1/0"]}], "requirement": "r"}
{"check": "c", "subject": {"id": [], "type": "tags"}, "passed": false, "cause": "absent", "message": "item 1 of tags is missing",
 "inputs": [{"name": "item 1 of tags", "value": null, "locations": ["/releases/tags/2"]}], "requirement": "r"}

1 can’t be compared with "a", so the second list fails as unusable, and the empty one has no first item, so it fails as absent, as first of v does on a field.

A subject that is a list of objects can be read field by field, without its name. every, some, count of and sum of read each item of the list a path starts at, and so do an item of a for line that has no position and the right side of is one of, is not one of, contains all of, contains none of, contains some of, does not contain all of, matches and does not match. With these params:

{"allowed": ["ana"]}

this input:

{"pipelines": [[{"stage": "build", "owner": "ana"}, {"stage": "test", "owner": "bo"}]]}

and this policy:

ergo_spec: v0.6
subjects:
  pipeline:
    from: every pipelines
requirements:
  owned:
    subject: pipeline
    checks:
      owners: every owner is in $params.allowed
      stages: count of stage is 2
      plain: owner is "ana"

the pipeline is one subject, a list of two stages, and the rows are:

{"check": "owners", "subject": {"id": [{"owner": "ana", "stage": "build"}, {"owner": "bo", "stage": "test"}], "type": "pipeline"}, "passed": false, "cause": "value", "message": "item 2 of pipeline: owner is \"bo\", and $params.allowed is [\"ana\"]",
 "failed_items": [{"cause": "value", "item": "item 2 of pipeline", "path": "pipeline[1].owner", "value": "bo", "locations": ["/pipelines/0/1/owner"]}],
 "inputs": [{"name": "pipeline[].owner", "value": ["ana", "bo"], "locations": ["/pipelines/0/0/owner", "/pipelines/0/1/owner"]}], "requirement": "owned"}
{"check": "plain", "subject": {"id": [{"owner": "ana", "stage": "build"}, {"owner": "bo", "stage": "test"}], "type": "pipeline"}, "passed": false, "cause": "not_an_object", "message": "pipeline is [{\"owner\": \"ana\", \"stage\": \"build\"}, {\"owner\": \"bo\", \"stage\": \"test\"}], which isn't an object",
 "inputs": [{"name": "owner", "value": null, "locations": ["/pipelines/0"]}], "requirement": "owned"}
{"check": "stages", "subject": {"id": [{"owner": "ana", "stage": "build"}, {"owner": "bo", "stage": "test"}], "type": "pipeline"}, "passed": true, "cause": "satisfied",
 "inputs": [{"name": "count of stage", "value": 2, "locations": ["/pipelines/0/0/stage", "/pipelines/0/1/stage"]}, {"name": "pipeline[].stage", "value": ["build", "test"], "locations": ["/pipelines/0/0/stage", "/pipelines/0/1/stage"]}], "requirement": "owned"}

The list is named after the subject, pipeline[].owner and item 2 of pipeline. A path without one of those words, like owner is "ana", fails as not_an_object, because the list has no owner. for: every owner as o with o is in $params.allowed goes through the stages the same way, while a for item with a position, like first of owner as o, fails as not_an_object. Only the list the path starts at is read item by item: a list directly inside it is an item that isn’t an object, so every owner on [[{"owner": "ana"}]] fails item 1 of pipeline as not_an_object. An empty list fails as value, and null as not_an_object. A check that reads a key of the subject with named by, like every age_days is less than $params.max_days named by severity, fails as not_an_object as a whole, because a list has no severity: to give each item its own limit, make each item a subject of its own, with a from line that reads each item. A part, a for alias and $input are read the same way at the start of a path, and a second for item that starts at an alias holding a list reads each item of it. A check that also reads a field of the subject outside those words, like every name is other or a for sentence o is boss, fails as not_an_object once, with pipeline is [...], which isn't an object, because the list has no other or boss. On an input that is a list, $input.id exists fails as not_an_object with $input is [{"id": "a"}], which isn't an object, and $input[0].id is "a" on [1] fails as unusable with item 1 of $input is 1, which isn't an object.

Four rules say what the name can do:

  • Nothing follows it, outside a relative clause. A step, a position or named by after the name, like pr.author, pr[0].y or pr named by k, is allowed only inside a relative clause, and the name alone everywhere. Outside one, the subject’s fields are bare words already, so region.name means nothing and is written wrong, and so are region[0], region."x", region named by $params.k and first of region.name. With the subject lib and the check lib.name is 1, the $well_formed row says lib.name: nothing can follow lib, which means the subject: for a field called lib, write .lib.name. Brackets straight after the name, like lib[0], are written wrong in the same way, with write .lib[0]. To read one item of a subject that is a list, write the position in words, first of lib or item 2 of lib. The name can’t be the key of a named by either: $params.m named by lib is 1 says named by lib: lib is the whole subject, so it can't name a key. Inside a relative clause, after whose or that, a bare word is the item’s field, so there lib.name reads the subject’s name: with the subject pr, count of approvers whose username does not equal pr.author is 1 counts the approvers who aren’t the pull request’s author. In pr named by k there, k is each approver’s own field, and an approver without one fails as absent, with the approver named, like item 2 of approvers: k is missing. To take the key from the subject, write it with the subject’s name after any path, like pr named by pr.k or $params.m named by pr.k: the key is the pull request’s k whatever the approver holds, and when the pull request has no k the check fails as absent with pr.k is missing, once. A name read from a root inside a relative clause, like pr.a.b, $params.a.b or a part, is the same for every approver, so its problem is said once with no approver named: pr.a is 3, which isn't an object.
  • A dot reaches the field. When an item has a field with the subject’s name, the bare name is the subject and .name is the field. With the subject build from builds and the input {"builds": [{"id": "b1", "build": "x"}, {"id": "b2", "build": "y"}]}, .build is "x" reads each item’s build field and build is an object reads the item. The row for b2 says .build is "y", with the input named .build, and the row of build is an object names its input build.
  • On the right side it reads the subject too. With the subject lib from every libs, some $input.deps whose name is lib exists keeps the dependencies whose name is the library. With the input {"libs": ["a", "b"], "deps": [{"name": "a"}]}, a passes, and b fails with $input.deps is [{"name": "a"}]. A name in double quotes on the right is a plain value, like any quoted text, so it never reads the subject there. With a dot, .lib on the right of a whose is the item’s own field: some $input.deps whose name is .lib exists keeps the dependencies whose name equals their own lib.
  • A name with a space goes in double quotes. With a subject called production deployment, the check is "production deployment" is an object, written in YAML as '"production deployment" is an object'. A quoted name that is a word of the grammar, like "item" for a subject called item, reads the subject too: "item" is a string on {"items": ["a", 2]} passes for a and fails for 2 with item is 2.

A subject can be called anything, it included. it isn’t a word of the grammar, so it is an ordinary name for a subject, a field, a for alias or a part.

The name goes where an assertion starts: the left side of a check, after and, and in an any_of option. After a for line it’s still the subject, not the item of the for. Right after whose and after sorted by, it’s the item’s field, like any bare word there, in a check, a for line and a from line alike. So from: every ws whose w starts with "b", for a subject called w, keeps the items whose own w field starts with b, and its $applies row names that field .w. To pick items by the value itself, use that.

that picks items by the value itself, where whose picks them by a field: from: every ws that starts with "b" keeps the values of ws that start with b. It works wherever whose does, on a from line, after every, some, count of and sum of in a check, and in an item of a for line, and the two mix in one chain, like ws that starts with "b" and whose w ends with "y". What follows that is a phrase about the item, and a field called that is written .that, like every items whose .that is "x".

On a from line, the $applies row names an item by the subject’s name. With from: every ws that starts with "b", a subject called w and the input {"ws": ["bay", "apple"]}, the row for apple has the input {"name": "w", "value": "apple", "locations": ["/ws/1"]} and the message w is "apple". In a check, count of ws that starts with "b" is 2 lists count of ws that starts with "b" and ws[] in its inputs, as whose does.

The report

The report of a policy, an input and params is:

{
  "compliant": false,
  "description": "",
  "engine": {
    "name": "ergo-rego",
    "version": "0.6.0"
  },
  "ergo": "v0.6",
  "meta": {},
  "requirements": { ... },
  "results": [ ... ],
  "subjects": { ... }
}

compliant is true when there’s at least one requirement and none of them is not_met.

description and meta are the policy’s, or "" and {} when it has none.

ergo is the version of the spec the report follows. engine says what made it: ergo-rego or ergo-rust and its version. It doesn’t say which OPA or which machine ran it, because that would make the report depend on the machine and not only on the policy, the input and the params. If you need to know, record it next to the report.

subjects says where the subjects come from, as Subjects in the report shows.

requirements has an entry for each requirement:

{
  "checks": {
    "$applies": {
      "description": "The deployment is in scope",
      "expression": "environment is \"prod\"",
      "meta": {}
    },
    "$min_subjects": {
      "description": "The in-scope deployment count is at least 1",
      "expression": "count(matching(deployments)) >= 1",
      "meta": {}
    },
    "$unique_ids": {
      "description": "Every deployment id is unique",
      "expression": "count(repeated(ids(deployments))) == 0",
      "meta": {}
    },
    "$well_formed": {
      "description": "The requirement is written correctly",
      "expression": "count(problems) == 0",
      "meta": {}
    },
    "approved": {
      "description": "Someone approved the deployment",
      "expression": "approved_by is a non-empty string",
      "meta": {}
    }
  },
  "description": "Every production deployment is approved",
  "meta": {},
  "require": "every",
  "status": "not_met",
  "causes": [{ "cause": "absent", "rows": 1 }],
  "subject": "deployment",
  "subjects": { "matching": 2, "total": 3 }
}

status is one of:

statusWhen
metthe requirement passed, and at least one subject was left to check
not_metanything else: a check failed, too few subjects were left, a condition couldn’t be read, or the requirement is written wrong
not_applicablethe requirement would have passed, but no subject was left after the subject’s conditions, which only happens with min_subjects: 0

To tell whether a requirement passed, compare status with "met" (or "not_applicable", if that counts as passing for you). Don’t test for "not_met", so a value you didn’t expect counts as a failure.

causes says why a requirement isn’t met, so a tool can tell a breach from missing evidence or a policy that’s written wrong without reading every row. It lists the causes of the rows violations would return for the requirement, each with how many rows have it, in the order of the cause table, so the first entry is the worst. It counts rows, not subjects, because one subject can fail several checks.

Take the README’s policy and this input:

{
  "deployments": [
    { "id": "d-1", "environment": "prod", "approved_by": "alice" },
    { "id": "d-2", "environment": "prod" },
    { "id": "d-3", "environment": "staging" },
    { "id": "d-4", "environment": "prod", "approved_by": "" },
    { "id": "d-5" }
  ]
}
"causes": [
  { "cause": "absent", "rows": 2 },
  { "cause": "value", "rows": 1 }
]

The two absent rows are d-2’s approved, because it has no approved_by, and d-5’s $applies, because its condition can’t read environment. The value row is d-4’s approved, whose approved_by is empty. d-3 is only out of scope, so it isn’t counted. With the check misspelt as approved_by is a non-empty strng, the requirement is written wrong, so $well_formed is its only row and causes is [{ "cause": "value", "rows": 1 }]. A requirement that’s met or not_applicable has "causes": [].

description and meta are the requirement’s, or "" and {} when it has none. They’re always there, so you can read meta.control without first checking that meta exists.

subject is the name of the subject the requirement checks, which is also a key of the report’s subjects. It’s null when the requirement names no subject, or one that isn’t in the policy’s subjects. subjects.total counts every subject found at from, and subjects.matching counts the ones left after the subject’s conditions. checks holds each check with its description and meta, "" and {} when you didn’t write them, and the expression, which is the sentence as Ergo prints it. Nothing else of the check is copied. A description or meta of the wrong type makes the requirement or the check written wrong, and the report shows "" or {} in its place, so a tool always finds a string and an object there. An expression you write is a field Ergo doesn’t know, so the check is written wrong. A check that reads a $params or $input path also gets $refs: the name, value and locations of each one, as read for this report. The $ marks it as Ergo’s, so it can’t be mixed up with a field of your own. It also holds the checks Ergo adds, each with a description, an expression and an empty meta.

In an expression, a value written in the policy is shown as JSON, written the same way whatever the policy looked like, so anyone can produce the same text:

  • A string is always in quotes: state == "MERGED", n == "1" and x == "" compare against strings, and n == 1, ok == true and x == null don’t. Only ", \ and control characters are escaped, as \", \\, \b, \f, \n, \r, \t or \u0001 and so on, so "a<b" stays as it is.
  • A number is a plain decimal, with no exponent and no trailing zeros: 1.0, 1.50, 1e2 and 2.5e-3 are shown as 1, 1.5, 100 and 0.0025, and -0 as 0. A number keeps every digit the policy wrote, so it’s only shown the same by every implementation when it has at most 15 significant digits, which is as many as any language’s 64-bit floating point number is sure to keep. The same goes for a number copied from the input into the report: a runtime that reads JSON into 64-bit numbers, like JavaScript, turns 12345678901234567890 into 12345678901234567000 before Ergo sees it. A number in the policy has to be 0 or have a magnitude between 2.2250738585072014e-308 and 1.7976931348623157e308, the range those numbers hold without losing digits. Elsewhere, a language like JavaScript turns 1e400 into Infinity and 1e-400 into 0, so a check could pass there and fail here. A check holding such a number anywhere is written wrong: it fails $well_formed, the requirement gets no other row, and the number is shown as <number out of range>. One in from, identified_by or min_subjects fails $well_formed. A number in the input or the params isn’t held to that range, because whoever writes the policy doesn’t control them. Ergo compares it exactly, so 1e400 equals 1e400 and 1e-400 doesn’t equal 0, and copies it into the report as written. An implementation that reads JSON into 64-bit numbers can’t do the same, so reports only match across implementations when every number in the input and the params fits that range.
  • A list or object has one space after each comma and colon, and its keys are sorted: ["a", 1.5, {"a": "x", "b": [true, null]}].
  • A ref is shown without quotes, as $params.x, so it can’t be mistaken for the string "$params.x".

Keys in paths are only quoted when needed, as described in Checks, and are escaped the same way.

Several of these rules depend on how a number was written, not only on its value: 1e-400 is out of range but 0 isn’t, and a number is shown with every digit the policy gave. An implementation has to read each number of the policy as written. In JavaScript, JSON.parse loses that, so it needs a parser that keeps the text, like the source that newer versions pass to a JSON.parse reviver.

results has one row for each subject and check:

{
  "requirement": "approved_deploy",
  "subject": { "type": "deployment", "id": "d-2" },
  "check": "approved",
  "inputs": [{ "name": "approved_by", "value": null, "locations": ["/deployments/1"] }],
  "passed": false,
  "cause": "absent",
  "message": "approved_by is missing"
}

Passing and failing rows have the same fields, and a failing row also has a message. A row of a check with every, some or a for line also has failed_items, and so does the $min_subjects row of a subject whose from line has every. So does an $applies row of a subject whose from line has one some or every condition, and one with several conditions has failed_assertions, like a check written as several sentences. To find a row’s description and expression, look up requirements[row.requirement].checks[row.check]. Look it up through the requirement, because two requirements can use the same check name for different things.

Messages

A failed row has a message: one line of plain text that says what Ergo found, so a reader who doesn’t read policies can follow it. A passed row has none. The message says what was found, never what was expected, because the check’s expression already says that. It comes from the policy and the input only, so the same report always has the same messages.

CauseMessageExample
valueeach value compared, joined with , andx is 4
absentthe inputs that are missinggone is missing
nullthe inputs that are nulln is null
unusablethe value, and the kind it isn’ts is "3", which isn't a number
not_an_objectthe subject, by its namedeployment is "str", which isn't an object
ambiguousthe items that tie, and the key they tie onitem 1 and item 2 of commits tie on ts

Where a row has no name for the subject, the message says the subject. A number Ergo works out is named after the words that asked for it, like count of l is 5. How values are printed:

  • Text is in quotes. Text over 60 characters keeps the first 60 and ends with โ€ฆ".
  • A list over 3 items shows the first 3 and then and N more, like l is ["a", "b", "c", and 2 more].
  • An object whose JSON is over 80 characters is printed as an object with N keys, like o is an object with 4 keys, which isn't text, or an object with 1 key.
  • A line separator inside text (U+2028, U+2029 or U+0085) is written as \u2028, \u2029 or \u0085, so a message stays on one line.

For every, some and for, the message lists only the items that failed, counted from 1, with ; between them. After three items it ends with ; and N more. With four items whose ok is false:

item 2 of items: ok is false; item 3 of items: ok is false; item 4 of items: ok is false; and 1 more

In a for line, the item’s fields are named without its alias when everything the item’s sentences read belongs to that item. With for: every commits as c and c.signed is true, the message is item 1 of commits: signed is false, and when the item itself is null it’s item 1 of commits is null. When a sentence also reads something else, like a field of the subject, a param or a second item, every name stays as the policy wrote it, like item 1 of approvers: approver.username is "ann", and author is "ann". A list inside an item is named without the alias, like item 1 of commits: item 2 of reviews: ok is false.

An alias or a part is named as the policy calls it, never with a $ before it: item 1 of z: k is 1, or item 2 of lst: x is 2, and cov.x is 1 for a part on the right of an every. Names the policy writes with a $, like $params.limit, keep it. A value that spells an alias, like "$z: x", prints as the input had it. A count of, a sum of or a whose that reads an item that isn’t an object names it. With the check count of commits whose signed is true is 3 and this input:

{"things": [{"id": "t", "commits": [null, {"signed": true}, 5]}]}

the message is:

item 1 of commits is null, which isn't an object; item 3 of commits is 5, which isn't an object

A check written as several sentences gives the message of each sentence that failed, joined with ; , like x is 4; y is missing. In an any_of, each option’s message starts with its name, like one: x is 4; two: y is missing.

The $applies row of a subject that is out of scope says what the condition found, like env is "staging".

Where a value was read

Every entry in a row’s inputs, in a check’s $refs and in failed_items has locations: where Ergo read its value, so a tool can point at the spot in the input. A location is a JSON Pointer from the root of the input, like /deployments/3/approved_by, and list positions count from 0. With the policy region is in $params.regions as the check region of deployment, the params {"regions": ["eu-west-1"]} and this input:

{"deployments": [{"id": "d-1", "region": "us-east-1"}, {"id": "d-2"}]}

the two rows are:

{"check": "region", "subject": {"id": "d-1", "type": "deployment"}, "passed": false, "cause": "value", "message": "region is \"us-east-1\", and $params.regions is [\"eu-west-1\"]",
 "inputs": [{"name": "region", "value": "us-east-1", "locations": ["/deployments/0/region"]}, {"name": "$params.regions", "value": ["eu-west-1"], "locations": ["$params:/regions"]}]}
{"check": "region", "subject": {"id": "d-2", "type": "deployment"}, "passed": false, "cause": "absent", "message": "region is missing",
 "inputs": [{"name": "region", "value": null, "locations": ["/deployments/1"]}, {"name": "$params.regions", "value": ["eu-west-1"], "locations": ["$params:/regions"]}]}

and the check’s $refs entry is {"name": "$params.regions", "value": ["eu-west-1"], "locations": ["$params:/regions"]}.

  • A value read from the params has $params: in front of a pointer from the root of the params, as $refs shows.
  • A read that stops early points at the last value it reached. d-2 has no region, so its location is /deployments/1, the deployment that should have held it. A pointer never leads to a value Ergo didn’t read.
  • An entry that holds one value for each item of a list, like the inputs of an every, has one location for each, in the same order. An entry that holds a number Ergo worked out, like count of findings, lists the locations of what it was worked out from.
  • A failed item points at where its value was read, in the failed_items of an $applies row as in those of a check. The failed_assertions of an $applies row or of a check without a for line hold no values, so they have no locations. Those of a for line have inputs, with locations of their own.
  • The entries of $well_formed are about how the policy is written, not about the input, so their locations are []. So is a list that’s missing, where there was nothing to read.
  • Two reads with the same name and value are one entry only when their locations match too. A parent’s b.x and a child’s own .b.x can print the same and be read from different places, so they stay two entries.

Subjects in the report

subjects has an entry for each subject the requirements use, by name. That’s the subject a requirement names, the subject it narrows or reads from, and so on all the way back. A subject no requirement uses isn’t listed.

Take this policy:

ergo_spec: v0.6
subjects:
  deployment:
    description: Something we ship
    from: deployments
    identified_by: id
  prod deployment:
    from: deployment whose environment is "prod"
  release:
    from: releases
requirements:
  approved:
    subject: prod deployment
    checks:
      approved: approved_by is a non-empty string

and this input:

{
  "deployments": [
    { "id": "d-1", "environment": "prod", "approved_by": "alice" },
    { "id": "d-2", "environment": "staging" }
  ]
}

The requirement’s entry has "subject": "prod deployment", and subjects is:

{
  "deployment": {
    "description": "Something we ship",
    "from": "deployments",
    "identified_by": "id",
    "parts": {}
  },
  "prod deployment": {
    "description": "",
    "from": "deployment whose environment is \"prod\"",
    "identified_by": "id",
    "parts": {}
  }
}

release isn’t there, because no requirement uses it. deployment is, because prod deployment narrows it.

Each entry has:

  • description is the subject’s, or "" when it has none. A description that isn’t a string is written wrong and shows as "".

  • from says how the subject is found, as a from line is written. A from written as a line is printed the same way whatever spaces it had. A subject without from reads the input field named like it. A from that is written wrong, because it isn’t a string or can’t be read, shows as <invalid from>. So does one that reads but has a problem, like values of mixed types, and the from of a subject with applies_to or of, because those fields said how it was found.

  • identified_by is the path of the subject’s id, or null when it has none, and the whole subject is its id. A subject that narrows another has the id of the one it narrows, also when its from is written wrong. An id that is written wrong shows as <invalid identified_by>.

  • parts lists every part the subject has, including the ones it keeps from a subject it narrows, by alias. Each is {"from": ..., "identified_by": ...}, with from written the way a sentence writes a path, so " dd.b " is dd.b and x[$params.k] is x named by $params.k, and identified_by the path of the part’s id or null when it has none. For the policy under Parts it is

    {
      "coverage": {"from": "compliance_status.attestations_statuses.coverage-verification", "identified_by": "attestation_id"},
      "findings": {"from": "compliance_status.attestations_statuses.findings.attestation_data", "identified_by": null}
    }
    

    A part that is written wrong is listed as best it can be, with <invalid from> for a path that can’t be read and <invalid identified_by> for an id that can’t.

A subject that is written wrong still has its entry, so nothing disappears from the report. A subject whose from starts at another subject lists that subject even when the line is written wrong, like every parent.items, and a subject that narrows another has that subject’s id then. A from that doesn’t parse, or isn’t a string, lists no other subject. An unknown name, like $foo in parent.items whose zz is $foo, only makes the line written wrong with unknown name $foo, so that line still lists parent. If the line has a syntax error too, like parent.items whose zz is $foo foo bar, it says doesn't parse and lists no other subject. With this policy and the same input, where deployment writes its from and identified_by as lists:

ergo_spec: v0.6
subjects:
  deployment:
    from: [deployments]
    identified_by: [id]
  note: {}
requirements:
  approved:
    subject: deployment
    checks:
      approved: approved_by is a non-empty string
  noted:
    subject: note
    checks:
      text: text is a non-empty string
  other:
    checks:
      x: x exists

subjects is:

{
  "deployment": {
    "description": "",
    "from": "<invalid from>",
    "identified_by": "<invalid identified_by>",
    "parts": {}
  },
  "note": {
    "description": "",
    "from": "note",
    "identified_by": null,
    "parts": {}
  }
}

and the three requirements’ subject are "deployment", "note" and null, because other names no subject.

Checks Ergo adds

Ergo adds five checks of its own. They start with $, so they can’t clash with yours.

CheckOne row perPasses when
$well_formedrequirementthe requirement names a subject in subjects, the requirement and its subject have only the fields Ergo knows and they have the right types, it has at least one check, a valid require, a from and an identified_by that are strings Ergo can read, no number a 64-bit float can’t hold in a sentence, in meta or in min_subjects, no set or key that isn’t a string in checks, a name that’s a string, and no check that’s written wrong. This depends only on how the requirement and its subject are written, never on the input or the params.
$min_subjectsrequirementfrom leads to a list or an object, or to an object when it ends with named by each of, and at least min_subjects subjects are left after the subject’s conditions.
$unique_idsrequirementno two subjects share an id, counting the ones its conditions leave out.
$unique_ancestor_idsancestorno two subjects share an id, for each ancestor of a child subject: its parent and grandparent, counted once for each of their own parents, and counting the ones their conditions leave out. These rows only exist when the subject has a parent.
$appliessubjectthe subject passes its conditions, the whose of its from line. These rows only exist when the subject has conditions.

The $well_formed expression is count(problems) == 0. The problems are the inputs below, one for each check or field that’s written wrong, so a requirement that’s written correctly has none.

A check is written wrong when it isn’t a sentence, an object with assert, or an any_of of sentences, when it has a field Ergo doesn’t know, or when a sentence can’t be read. The $well_formed row gets an input for it, named after where the check sits in the policy, with the list of what’s wrong. With this input:

{"deployments": [{"id": "d-1", "environment": "prod", "approved_by": "alice"}, {"id": "d-2", "environment": "staging"}]}

and a deployment subject read from deployments, whose from is the list [deployments], with these checks:

approved: {op: non_empty_string, path: [approved_by]}
reviewed: {assert: approved_by is a non-empty string, descripton: Someone approved it}
signed: signed is

the inputs are:

"inputs": [
  { "name": "count(checks)", "value": 3, "locations": [] },
  { "name": "require", "value": "every", "locations": [] },
  { "name": "subjects.deployment.from", "value": ["not a string"], "locations": [] },
  { "name": "checks.approved", "value": ["missing assert", "unknown field op", "unknown field path"], "locations": [] },
  { "name": "checks.reviewed", "value": ["unknown field descripton"], "locations": [] },
  { "name": "checks.signed", "value": ["doesn't parse"], "locations": [] }
]

A problem with a sentence in an any_of option is listed under the check, with the option’s name in front, like option standard: line 2: doesn't parse or option standard: line 1: values of mixed types under checks.permitted. A problem with one line of an assert list of two or more starts with the line’s number, like line 2: values of mixed types under checks.signed, and a problem with the for line, its whose included, starts with for: , like for: values of mixed types. A problem with a sentence is listed under the check, never under a place inside it, and a sentence lists every problem it has, so x is one of 1, 'a' and y is at least y gives both values of mixed types and y is compared with itself. A name that needs quotes is quoted: checks."a.b".

The fields of the requirement and its subject get an input of the same form when something is wrong with them. After count(checks) and require, the requirement’s own fields come first, named after the field, then the subject’s, named after the subject and the field, like subjects.deployment.from, and then the checks, named checks.<name>. Each group is sorted by the name as the report writes it, quotes and escapes included, by code point, so a field called zz comes before subjects.deployment.from, and "x y" (a name that needs quotes) comes before x. require is always second: it shows its value when it’s fine, and what’s wrong with it instead when it isn’t, so no name appears twice. With a requirement like this on the same subject, with from: deployments:

approved:
  subject: deployment
  checks: {}
  min_subjects: -1
  require: all
  zz: 1

the inputs are:

"inputs": [
  { "name": "count(checks)", "value": 0, "locations": [] },
  { "name": "require", "value": ["neither every nor some"], "locations": [] },
  { "name": "checks", "value": ["empty"], "locations": [] },
  { "name": "min_subjects", "value": ["not a whole number from 0 to 1000000"], "locations": [] },
  { "name": "zz", "value": ["unknown field"], "locations": [] }
]
FieldWhat’s wrong
any field Ergo doesn’t knowunknown field
checksnot an object, holds a key that isn't a string, missing, empty
checks.$foo, any name with a $a name can't start with $
checks."", any empty namea name can't be empty
requireneither every nor some
min_subjectsnot a whole number from 0 to 1000000, number out of range
subjectmissing, empty or not a string, not in subjects
subjectsnot an object
subjects.deploymentnot an object
subjects.deployment.from, ...identified_bynot a string, doesn't parse, number out of range: 1e400, or the reason a line can’t be read, like unclosed quote at column 24
subjects.deployment.fromleads to a loop, " " isn't a subject name
subjects.deployment.descriptionnot a string
subjects.deployment.hasnot an object
subjects.deployment.has.coveragenot a string or an object, missing from, unknown field foo, invalid from, invalid identified_by, from: ..., identified_by: ..., invalid alias, x can't be an alias: it's a subject's name, x can't be an alias: it's a part of ...

A policy written in Rego can use a key that isn’t a string, like true or 1.5. Ergo names it <invalid key true> or <invalid key 1.5>, so two such keys never share a name. A requirement whose own name isn’t a string, like 1, or every requirement of a policy written as a list, fails $well_formed, which then starts with {"name": "requirement name", "value": ["not a string"], "locations": []}. The requirement is not_met, so the policy isn’t compliant.

Their descriptions are plain sentences, like your own checks’: The requirement is written correctly, The in-scope deployment count is at least 1, Every deployment id is unique and The deployment is in scope. The details are in expression, inputs and cause. $min_subjects names its input after what it counts, like in-scope deployment count: only the subjects left after the subject’s conditions. With min_subjects: 0 the count can’t fail, so $min_subjects only checks that from can be read, and says so: The deployment list can be read, with the expression deployments can be read. A from line with named by each of keeps the count, because its subjects are the keys in the params list, so there’s no list to read. $unique_ids lists the ids that more than one subject has, once each, under repeated deployment ids, sorted by how they’re written as JSON, so "b" comes before 10, and 10 before 3, and $unique_ancestor_ids lists them the same way under repeated artifact ids. They use the subject’s name as it’s written, so they read right whatever the word’s plural would be.

A subject that fails $applies gets no other rows, since it was never checked. But its $applies row stays, so you can see what was left out and why.

An $applies row has the fields the row of a check would have if its conditions were the assertions of that check. One some or every condition gives failed_items, which is [] when it passes. Two or more conditions give failed_assertions, the conditions that failed in the order written with their own causes, and [] when they all pass, but no failed_items. A single condition that isn’t a some or an every, like environment is "prod", adds no field, as a plain check doesn’t. An entry of failed_assertions can have a cause the row doesn’t: when a present condition rules the subject out, the row’s cause is value and the entry of another condition can be absent.

A subject is only out of scope when Ergo read a condition’s fields and the values didn’t match, so the row’s cause is value. When a condition fails because a field it reads is missing, null or can’t be found, Ergo can’t tell whether the subject is in scope. Its $applies row then fails with that cause, the requirement isn’t met, and the row shows up in the violations. This holds with min_subjects: 0 too, so a missing field can’t quietly make a requirement pass.

exists is the exception, because a missing or null field is exactly what it checks for. whose lock_release exists leaves out a package with no lock_release. A parent that can’t hold the field, like "build": "abc" for build.fingerprint, or a subject that isn’t an object, still fails the requirement.

With several conditions, the subject is only out of scope when every condition that failed did so with value. If one rules it out and another can’t be read, the requirement fails, so missing data always shows. An exists that finds its field missing is the exception: it says on purpose that a missing field means out of scope, so it rules the subject out whatever the other conditions read. That lets a condition on the same field sit next to it. With this input:

{"lockfiles": [{"name": "a", "status": "COMPLETE"}, {"name": "b"}, {"name": "c", "status": "PENDING"}]}

from: lockfiles whose status exists and whose status is "COMPLETE" keeps a, and leaves out b and c, both with cause value, although status is "COMPLETE" can’t read b’s status.

A condition that’s written wrong still fails the requirement.

Together, these make sure that whenever a requirement isn’t met, at least one row explains why.

Row order

Rows always come in the same order, whatever order you wrote the policy in:

  1. all the $well_formed rows, then all the $min_subjects rows, then all the $unique_ids rows, then all the $unique_ancestor_ids rows, outermost ancestor first, then all the $applies rows, then your own checks
  2. within each group, requirements in name order
  3. within a requirement, subjects in the order they appear in the input, or in key order when named by each of reads an object
  4. within a subject, checks in name order

Patterns, options and the keys of objects written in the policy are sorted in rendered expressions too. Names, keys and strings are sorted by Unicode code point, so ๏ผ (U+FF01) comes before ๐Ÿ˜€ (U+1F600). JavaScript’s default sort() puts them the other way round, so an implementation there needs to compare code points. So the same policy and the same input always produce exactly the same report, byte for byte once it’s written as JSON the same way, which means you can hash it and compare hashes. Every number in the report is written in its plain form, as in expressions, wherever it came from: 1.50, -0.0 and 1e2 in the policy, the params or the input come out as 1.5, 0 and 100. Two things are left as they are. A number a 64-bit float can’t hold, like 1e400 in the input, is copied as written. And a report holding something JSON can’t, like a set from an input written in Rego or a requirement whose name isn’t a string, is left as Ergo built it, so that its rows still name their requirement. opa eval sorts the keys. OPA’s JavaScript runtime for Wasm doesn’t, and it reads the report into JavaScript numbers, which writes a very large or very small one with an exponent: 1e21 and 0.0000001 come out as 1e+21 and 1e-7. So to compare reports across runtimes, write each one with the JSON Canonicalization Scheme (RFC 8785) before hashing: it sorts keys and writes numbers the way JavaScript does.

Causes

Every row has a cause. A missing field and a field set to null both show up as null in inputs, but they’re different problems with different fixes. The cause tells them apart.

causeMeaning
satisfiedThe check passed.
not_an_objectThe subject isn’t an object, so it has no fields.
ambiguousAnother item ties with the one a for line picks sorted by a field.
unusableA value the check reads is there, but it’s not the kind the check needs, like a string for range, a string where a path needs an object, or a param of the wrong type.
absentA field the check reads isn’t there.
nullA field the check reads is there, but null, or a value on the way to it is null, like c in c.z, which the message names: c is null.
valueEverything was read fine. The values just don’t pass.

When the input or the params hold something JSON can’t, every check on a subject, every $applies row and every $min_subjects row fails as unusable (see Failing closed).

When a check reads several fields, the row shows the first cause in this table’s order. An ambiguous pick matters more than any value, because it means the policy can’t even tell what it’s looking at.

  • For every, some and for lines, a list that isn’t there gives absent, and one that isn’t a list gives unusable. An empty list gives value, since it was read fine and just has nothing in it. A subject that is a list is read item by item like any list (The subject itself). An item that isn’t an object, null included, fails as not_an_object when the path goes on into it: with t holding ["a"] or [null], every t.k is 1 and a for line every t.k as x both fail item 1 of t that way, and so do count of t.k and sum of t.k. Otherwise the cause is the first, in this table’s order, among the items that failed. So when one approver is a bot and another has no username, a check that some approver isn’t a bot fails as absent, because the second one might not be. An any_of works the same way across its options, and so does a path through several lists.
  • $well_formed and $min_subjects don’t read the subject, so their cause is satisfied or value, except that $min_subjects fails as absent, null or unusable when a $params or $input path after named by in from can’t be read, or when from doesn’t lead to a list or an object. $applies reports the state of the fields read by the conditions that failed. Its inputs list each field its conditions read once, sorted by name, so two conditions on status show status once. For example, a subject whose condition field is missing says absent, and that fails the requirement. A condition that’s written wrong makes the requirement written wrong, so it gets no $applies rows at all.

Violations

The violations are the rows of the report that are real problems, each with its check’s description and expression added, and its $refs added to the end of inputs, each once: a ref the row lists already isn’t added again. Each keeps its row’s message. For the deployment {"id": "d-2", "approved_by": null}:

[
  {
    "requirement": "approved_deploy",
    "subject": { "type": "deployment", "id": "d-2" },
    "check": "approved",
    "description": "Someone approved the deployment",
    "expression": "approved_by is a non-empty string",
    "inputs": [{ "name": "approved_by", "value": null, "locations": ["/deployments/1/approved_by"] }],
    "cause": "null",
    "message": "approved_by is null"
  }
]

A violation of a check with every, some or a for line, or of the $min_subjects row of a subject read with every, keeps its row’s failed_items, and one of a check written as several sentences its failed_assertions. A violation that’s an $applies row keeps the failed_items or failed_assertions of its row too.

It leaves out:

  • rows that passed
  • $applies rows with the cause value, since being out of scope isn’t a problem. An $applies row that failed because a condition couldn’t be read is kept.
  • every row of a requirement whose status is met or not_applicable. Under require: "some", other subjects can fail while the requirement still passes, and those failures aren’t problems.

It keeps the rows of a requirement with any other status, or none. It keeps $min_subjects failures, because finding nothing to check is a problem, and $well_formed failures, because they mean the policy itself is broken.

It only reads the report, never the input or the policy. It returns a list in the same order as results, so two failures that look the same are both kept. And it returns data, so you can word your own messages from inputs instead of using message. A missing description or expression comes back as "".

To decide whether to allow something, use report.compliant, not whether violations is empty. The two are worked out separately, so a mistake in how you use violations can’t let something through.

Failing closed

Ergo fails a check whenever it can’t be sure, instead of letting it pass. Rego doesn’t do that by default in a few places, so these are handled on purpose. Some of them name the operators that sentences run as, as the table in Checks shows:

  • A missing, null or wrong-typed field fails every operator, except missing, which passes on a missing or null field.
  • compare needs both sides to exist and have the same type. In plain Rego, null < 5 is true, so a missing field would otherwise pass a lt check. Ordering objects, lists or booleans fails too.
  • every, some and for lines need non-empty lists, inner lists included. A policy that’s fine with no items says so with , if any,.
  • An alias that clashes with a subject or a part, or is badly written, fails the check, so an inner alias can’t quietly hide an outer one.
  • A part that is missing, null or the wrong type fails a check exactly as the full path it stands for would, and a part that is written wrong, or is named like a subject or a part of a parent (see Parts), fails $well_formed and is never read as an alias.
  • A check that’s written wrong, like a sentence that can’t be read or an object with an op, fails $well_formed, and the requirement gets no other row, so a mistake in a subject’s conditions can’t rule every subject out.
  • A value of the wrong kind, like a string where range needs a number, or a param of the wrong type, fails with cause unusable, so a condition can’t rule a subject out on it.
  • With several conditions, one that can’t be read fails the requirement, even when another rules the subject out. The only condition that can rule a subject out regardless is an exists that found its field missing.
  • min_subjects is 1 unless you say otherwise, so finding nothing fails. A from that leads nowhere, to null or to something that isn’t a list or an object fails even with min_subjects: 0.
  • A key after named by each of that the input doesn’t have is still a subject, so it fails instead of being skipped.
  • A subject whose conditions can’t be read fails the requirement instead of being left out.
  • A $params or $input path that can’t be read fails the check, even for phrases like does not contain or does not match that would pass on an empty value.
  • A policy with no requirements, and a requirement with no checks, are never met.
  • Ergo only reads what JSON can hold. Rego can build sets and object keys that aren’t strings, but JSON and YAML can’t, so Ergo turns both down and gives the same report however it’s run. A set or such a key anywhere in the input or the params fails every check on a subject, every $applies row and every $min_subjects row with cause unusable, so even a requirement with no subjects isn’t met. $min_subjects says why in its inputs, like {"name": "$input", "value": ["holds a set"], "locations": []}, or $params for the params. In a policy, a requirement or check holding either one is written wrong, and so is a requirement whose name isn’t a string. So when you build the input in Rego, make lists, [x | ...], not sets, {x | ...}.
  • A requirement that isn’t an object, a field of the wrong type, or a field Ergo doesn’t know, fails $well_formed and keeps its place in the report, so a typo in the policy can’t make a requirement vanish.
  • A malformed timestamp fails compare_time rather than stopping the whole evaluation with an error.

Policies written before 0.1

Before 0.1, a check was an object with an op and the fields that operator takes, like {op: non_empty_string, path: [approved_by]}, and a path was a list of keys. A subject was found with a from list, like [deployments], and could have applies_to filters. The old checks also had all, any, inputs, literal, {"ref": ...} values and selectors, and the old subjects had of, as, each_as and keys. Spec 0.1 doesn’t read any of this.

A policy that still uses it is written wrong. Nothing is skipped, and the report says what is wrong. With this input:

{"deployments": [{"id": "d-1", "environment": "prod", "approved_by": "alice"}]}

a check written as {op: non_empty_string, path: [approved_by]} has no assert, and op and path are fields Ergo doesn’t know. Its expression is <missing assert>, the requirement gets no other row, and $well_formed has this input:

{"name": "checks.approved", "value": ["missing assert", "unknown field op", "unknown field path"], "locations": []}

A from written as the list [deployments] isn’t a string. The requirement is written wrong, the subject is listed with <invalid from>, and $well_formed has:

{"name": "subjects.deployment.from", "value": ["not a string"], "locations": []}

applies_to and of are fields Ergo doesn’t know, so each gets ["unknown field"], like {"name": "subjects.deployment.applies_to", "value": ["unknown field"], "locations": []}.

ergo.rego has a function that rewrites what it can as sentences: see Moving a policy to sentences.

Source: REFERENCE.md in spec-v0.6. Edit it on main, and this page follows at the next release.