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
- Subjects
- Requirements
- Checks
- The report
- Causes
- Violations
- Failing closed
- Policies written before 0.1
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
| Field | Meaning | Default |
|---|---|---|
description | What the subject is, in words anyone can read. The report copies it into its subjects. | "" |
from | Where 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_by | The path, inside one subject, to the value that identifies it. | the whole subject |
has | Names 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
fromleads 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, tonull, or to anything else, like a string or a number, there are no subjects and$min_subjectsfails with causeabsent,nullorunusable, even withmin_subjects: 0, because that’s what a typo infromlooks like. A path through something that can’t hold the next key, like"build": "b1"forbuild.items, givesunusable, and a missing ornullvalue along the path givesabsent, as they do in a check. An empty list is different: it was read fine, so it fails$min_subjectsasvalue, or passes withmin_subjects: 0. - The input has to be an object. A
nullinput fails$min_subjectsasnullwhenfromis empty or left out, and asabsentwhen it has a step. Any other input that isn’t an object, a list included, fails it asunusable. - If
identified_bydoesn’t lead anywhere, the subject’s id isnull. Its rows are still there. A subject whosefromusesnamed by each oftakes its key as its id, so it can’t have anidentified_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_idsfails and so does the requirement. Every subject counts, even one that its conditions leave out, andnullis 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_idsrows. - 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 causenot_an_object. - Without
from, a subject reads the input field named like it, soartifactreadsartifact. A subject with nothing under it,artifact:alone in YAML, does the same. To check the whole input as one subject, writefrom: $input. When the input is a list,from: $inputandfrom: every $inputmake each item a subject, asfrom: thingsdoes on a list calledthings, with locations like/0/x. When a walk from the input breaks in one item, likecommitsbeing5in the second item forfrom: every $input.commits,failed_itemsnames the place$input[1].commits. When the walk breaks on the input itself, like an object ornullforfrom: every $input, the place is named$input. The expressions of$min_subjectsand$unique_idsname a position on the input from$inputtoo, likecount(matching(item 1 of $input)) >= 1forfrom: $input[0], and write afromthat starts withnamed byas it is, likecount(matching(every $input named by $params.k)) >= 1. A position, likefrom: $input[0]orfrom: first of $input, reads the item at it, and endsabsentwhen the list has none.from: every $input[0]makes each item of that item a subject. Withoutidentified_by, each row repeats the whole subject as its id, so two identical subjects clash. - A subject that’s written wrong fails
$well_formedon 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, likesubject_type,applies_toorof, or whosefromoridentified_byisn’t a string or can’t be read, or whosedescriptionisn’t a string. The$well_formedrow 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_formedrow, as for a requirement that’s written wrong, and afromthat 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.namepicks one key:from: trail.artifacts named by $params.artifact_name. When the param is missing,nullor not a string, there are no subjects and$min_subjectsfails asabsent,nullorunusable, also witheveryin front of the line. A number is not a key and never reads an item of a list, sox named by $params.kwith{"k": 1}fails asunusable.named by each of $params.kindsmakes 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 anidentified_byon that subject is written wrong, withnot allowed with named by each of: the key is the id. When the object itself is missing,nullor not an object, a list included, each key is still a subject, but$min_subjectsfails asabsent,nullorunusable, 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_subjectsfails asnot_an_object. When the list is missing, isn’t a list, or holds anything but strings, there are no subjects and$min_subjectsfails asabsent,nullorunusable. The list is read from$paramsor$input. A path that starts anywhere else, liket.keys,keysor.keys, is written wrong, withnamed 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 beforenamed by each of, like$params.envinscans named by $params.env named by each of $params.kinds, is read first: when it’s missing,nullor not a string,$min_subjectsfails asabsent,nullorunusable, 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
requiredis a list of objects:{ "scans": {"snyk": {"status": "pass"}, "trivy": {"status": "pass"}}, "required": [{"kind": "snyk"}, {"kind": "trivy"}] }there are no subjects, and
$min_subjectsfails:{ "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$refsof$min_subjectsand$unique_idslist$input.required.kindwith the valuenull.A condition can ask about the items of a list with a one-line
someorevery, 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
$appliesrows keep1.4.0, which changes a file outsidedocs/, and leave the other two out. They read, and list asfailed_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
filesfield at all isn’t out of scope: Ergo can’t tell, so the row fails asabsent(nullandunusablefor anullor 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, andevery files.path, if any, matches "^docs/"keeps the ones with no files too.sometakes no, if any,. A bare name aftersomeoreveryis the item’s, and a parent subject’s name on the right is the parent. The condition can’t have awhoseof its own, and awhosein a check or on aforline takes nosomeoreverycondition.A subject’s own name at the start of its
fromis the input field, solockfilecan havefrom: lockfile whose status is "COMPLETE".A
fromline that’s another subject’s name, with or without awhose, narrows that subject (a position after the name, likep[1]orfirst of p, doesn’t narrow it: the subject reads that position inside eachp, and fails$min_subjectsasunusablewhen apis an object), as in a subject built on another:critical defectwithfrom: defect whose severity is "critical"keeps the defects that passdefect’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 anidentified_byof its own is written wrong, withnot allowed on a subject that narrows another. When that subject is read withevery, the narrowing subject takes its flat list of subjects and filters it, and its$min_subjectsexpression starts witheverytoo.A
fromline with one name, likedeploymentsor.deployments, gives each item of a list as a subject. A line with more than one name needseveryto read a list, wherever the list is:$input, another subject’s name, each field and each key anamed byreads count as names, and a position like[0]orfirst ofdoesn’t. So$input.deployments,build.tests,build named by $params.kandbuilds[0].testsneedeverywhen the value they reach is a list, andbuilds[0].tests[0]doesn’t. Without it,$min_subjectsfails asunusableand says what to write, liketrails 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
fromline 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"},versionwithfrom: versionis a subject, andversion is "1.2.0"passes. An input that is itself text still fails$min_subjectsasunusable.All the subjects a
fromline gives must be of one type: objects, lists, text, numbers, or true or false.from: tagson{"tags": ["a", {"x": 1}]}fails$min_subjectsasunusable, even withmin_subjects: 0, withitem 1 of tags is text, item 2 of tags is an object, and both are still subjects and are checked. The row’sfailed_itemslists the values the message names,item 1 of tagsanditem 2 of tags. An item that isnullis a subject too and doesn’t count as a type. Only that line’s$min_subjectsfails: a child subject read from it is still read under each parent, so withdfromdsandmfromd.m,{"ds": [{"m": {"up": false}}, "x"]}givesmunder the firstd, andd is "x", which isn't an objectunder the second.A
fromline 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
webartifact:subjects: artifact: from: trail.artifacts named by $params.artifact_name identified_by: fingerprint pull request: from: every artifact.pull_requests identified_by: urlThe 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 onpull requestsays which, underin, 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
whosein the child’sfromline. 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.branchkeepspr/7and leavespr/8out 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.branchis the pull request’s own field, as in anywhose.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 1the 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_subjectsand$unique_idsrows.sha-2has no pull requests, so it fails on its own row, unless the requirement hasmin_subjects: 0. Ids only need to be unique within each artifact, sopr/7under two artifacts is fine.The artifacts’ own ids are checked once, on the
$unique_ancestor_idsrow, with noin, countingsha-3too, as$unique_idscounts the subjects its conditions leave out. Two artifacts with the same fingerprint fail it, and so does the requirement, even withrequire: some, where the pull requests of both artifacts would otherwise count as one artifact’s. Withcommitread fromcommitsbysha,pull requestfromevery commit.pull_requests, and two commits that both have the shaa, 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-3is out of scope, so its pull requests aren’t subjects at all and it has no rows. Nothing in them is read, sopr/9isn’t checked.sha-4has noenvironment, so Ergo can’t tell whether it’s in scope. Its$min_subjectsrow fails with the cause, even withmin_subjects: 0, and its pull requests aren’t checked.When no artifact is in scope, there’s one
$min_subjectsrow with noin. It fails withvalue, or passes withmin_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: shaEach 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_subjectsand$unique_idsrows. The requirement gets one$unique_ancestor_idsrow for the artifacts, with noin, then one for the pull requests of each artifact in scope, with the artifact underin, 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_subjectsrow, with"in": {"artifact": ...}. A check reads any ancestor by its name, likeartifact.nameor"pull request".title, and its parts too. A fourth level is written wrong, witha 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, likebwithfrom: kid.xandkidwithfrom: b.kids, is written wrong withleads to a loop, however many subjects it goes through.A parent that isn’t an object, a string or a
nullfor instance, can’t hold the child’s field, so its$min_subjectsrow fails asnot_an_object, withinup to the parent, even withmin_subjects: 0. A parent that’s an object without the field still givesabsent, and afromthat starts with a position, likeartifact[0].commits, keeps the cause it had. A parent that is a list is read withevery: withbatchread frombatches,every batch.ownergives the owners in each batch, with the batch underin, andbatch.ownerfails asnot_an_objectwithbatch is a list: write every batch.owner to read each item.everynever stands for the parent itself:every batchandevery batch whose size is 2are written wrong, withbatch 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
unitcan appear in every build, and every row names the parent underin, 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 truethere are four test runs,
unitandintegrationunder each build, and onlyintegrationinb2fails:{"check": "passed", "subject": {"type": "test run", "id": "integration", "in": {"build": "b2"}}, "passed": false, "cause": "value", "message": "passed is false"}everycan’t go withnamed by each of, soevery builds.test_runs named by each of $params.kindsis written wrong: the keys would be the ids, and with two builds the rows wouldn’t say which build they belong to. When afromline meets a list beforenamed by each of, likebuild.test_runswithtest_runsa list,$min_subjectsfails asunusablewithbuild.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.kindswhen the input is a list, which says$input is a list, but โฆ. A parent subject that is a list, underartifact named by each of $params.kinds, fails asnot_an_objectwithartifact is a list, but โฆ.A
fromline that starts at another subject’s name and ends withnamed by each of, likeartifact 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 underin.artifact.x named by each of $params.keysreads the keys insidexof each parent. A parent that isn’t an object fails$min_subjectsasnot_an_objectand makes no subject of its keys. A missing,nullor wrong-typed list fails$min_subjectsof each parent asabsent,nullorunusable. A first key written with a dot, like.artifact named by each of $params.keysor.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, likeartifact[0] named by each of $params.keysorfirst of artifact named by each of $params.keys, reads the parent as a list:$min_subjectsfails asunusablefor each parent that’s an object, and the keys are still subjects that fail their checks.everyin 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 1the pull requests are one list,
pr/7andpr/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, ornullfails$min_subjectswith the worst cause found, even withmin_subjects: 0. The row’sfailed_itemssays where, and the pull requests that could be read are still checked. So does a path with no list along it:every depson{"deps": {"id": 1}}fails withdeps 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.versionon{"builds": [{"version": "1.0"}, {"version": "1.1"}]}gives"1.0"and"1.1". - A name
everyreads as a list in one item must be a list in all of them.every builds.tagson{"builds": [{"tags": ["a", "b"]}, {"tags": "c"}]}givesa,bandc, and$min_subjectsfails asunusablewithtags is a list in item 1 of builds but "c" in item 2, and listsbuilds[0].tagsandbuilds[1].tagsinfailed_items. A policy that expects both shapes can say so with a subject for each, likelisted buildwithfrom: build whose tags is a listandtagwithfrom: every "listed build".tags. - A list directly inside a list is an item like any other, and
everydoesn’t read into it. With{"batches": [[{"owner": {"name": "ann"}}], [{"owner": {"name": "cy"}}]]},every batchesgives two subjects, each a list, andevery batches.ownerfails asnot_an_object, because a list has noowner. A subject for each level,batchandevery batch.owner, reads the owners. Text, a number or true or false where a name is read fails asunusableinstead, with the message a check gives:every builds.version.xon{"builds": [{"version": "1.0"}]}saysitem 1 of builds: version is "1.0", which isn't an object. everycan start at another subject’s name, likeevery artifact.pull_requests, which reads the pull requests of each artifact, with the artifact underin, as a child subject does.everycan’t go withnamed by each of, which is written wrong. A path that starts with a dot, likeevery .artifact.p, reads the input field.
- Unlike a child subject, these pull requests don’t belong to an artifact. Their rows have no
every distinctmakes 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 existsthere are two subjects, and each row of
clicklists 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:
1and1.0are 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
clickwith version8.2, are all kept, and$unique_idsfails 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 nodistinct bya field. - Copies are merged before the
whoseis read, so they get one$appliesrow, and the rows of a child subject, likefrom: every component.licenses, list the location under every copy too. A path that starts at$input, likeversion is $input.sbom.components[2].version, lists only the location it names. distinctgoes aftereveryon afromline. Withoutevery, likefrom: distinct sbom.components, it is written wrong.
- Two values are the same when they’re equal as JSON:
A
fromoridentified_bythat can’t be read is written wrong, and the$well_formedrow says why, like{"name": "subjects.x.from", "value": ["doesn't parse"], "locations": []}.A
fromthat can’t be read fails$min_subjects, and itsmessagesays where the read stopped:item 2 of things is missing, orthings is 5, which isn't a list or an object. A child subject names the path inside its parent, without the parent’s name, likews is missingoritem 1 of ws is missing. When the read stops before its first step, the message names$input, like$input is null, or the parent, likeartifact is null,artifact is "x", which isn't an objectoritem 1 of artifact is missingfor a child ofartifact.
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:
| requirement | subject | check | passed | cause |
|---|---|---|---|---|
licences | a | licence_known | true | satisfied |
licences | c | licence_known | false | value |
versions | a | matches_lock | true | satisfied |
versions | b | matches_lock | false | value |
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_formedon 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
componentto 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, andidentified_by, a path read inside the part. The report shows it underparts, and tags each input read through the part with the id it leads to. Acount oforsum ofhas the tag only when the list it counts or sums starts at the part:count of items whose w is pa.wcounts the subject’sitems, so its number has no tag, while the entry forpa.w, which it reads, has.A path is written as in a from line: keys joined by
., keys in quotes, positions, andnamed bya key of$paramsor$input. It is a path inside the subject, so it can’t start at$paramsor$input, and it can’t havewhose,count ofornamed 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.ratiomakesratio is at least 0.8read the same.Keep the data’s own names, as
findingsandcoveragedo, because a reader who knows the data findscoverage.attestation_data.hunks_totalwhere 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
forline, and in the conditions of the subject’sfromline and of a subject that narrows it, where.limitreads the field instead. In awhose, where a bare first key is a field of the item, it’s read on the right side and never right afterwhose, as aforline’s name is:whose limit is at least 4reads the item’slimit, and.limiton the right of awhosereads the item’s. After a one-lineeveryorsome, the right side is read from the subject, soevery items.price is at most limitreads the partlimit, and.limitthere reads the subject’s own field. A term of aplusand the path of an elapsed time on the right read it the same way, sowhose lo is at least limit plus hireads the partlimitand the item’shi. Acount oforsum ofis read the same way: incount of items whose n is count of limit.rows is 1the right side counts the part’slimit.rows, and incount of items whose count of limit.rows is 1 is 3the left side counts each item’s ownlimit.rows. Afterthat,count of xs that is count of limit.rows is 1counts the part’slimit.rowstoo, even when the items are numbers.named bya part’s alias is written wrong, withnamed 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:
.coverageis the subject’s fieldcoverage, not the part. A row’sinputsname a field read like that with the dot, like.coverage.pct, when the field is named like a part, a parent subject or aforalias, so it isn’t taken for the part’scoverage.pct. The list acount ofor a walk reads keeps the dot too, like.coverage.items[].An alias is a bare word that isn’t a word of the grammar,
paramsorinput, or it is written wrong withinvalid 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, likesubjects.kid.has.x.An alias of a
forline 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 nameor<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_formedrow fails with{"name": "checks.c", "value": ["b can't be an alias: it's a subject's name"], "locations": []}, and withas capit fails with["cap can't be an alias: it's a part of b"]. A name that clashes with nothing, likeas 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
fromline 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_attestationin a check ofpull requestis one of its own fields.A part that is written wrong fails
$well_formedon 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 ahasthat 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 withoutfrom(missing from), with a field other thanfromandidentified_by(unknown field <name>), or with afromoridentified_bythat 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
| Field | Meaning | Default |
|---|---|---|
description | What the requirement asks for, in words anyone can read. It’s copied into the report. | "" |
meta | Anything 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. | {} |
subject | The 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_subjects | How many subjects must be left after the subject’s conditions for the requirement to be met, from 0 to 1,000,000. | 1 |
checks | Named checks that each subject must pass. | required |
A few details:
metacan 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, like1e400, 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, so1.50,1e2and-0come out as1.5,100and0, and the samemetagives the same report however it was written.min_subjectsdefaults to 1 so that a typo infromfails the requirement instead of quietly passing it. Set it to0when you mean “if there are any, they must pass; if there are none, that’s fine”. It means the same undereveryandsome. When none are left, the requirement is not applicable. That only holds for a list that’s there: afromthat leads nowhere still fails, as above. Afromthat ends withnamed by each ofis the exception, because each key is a subject even whenfromleads 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
requireother thaneveryorsome, a check that’s written wrong, or a subject that’s written wrong, is never met. The$well_formedrow 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,$appliesor check rows, and itssubjectscounts are 0. Its entry inrequirementsstill 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, anany_ofoption or a part, because the report would mix it up with Ergo’s own rows, like$well_formed. A$later in a name, likea$b, is fine, and so is$inputor$paramsat the start of a path, which aren’t names. The$well_formedrow says{"name": "checks.$foo", "value": ["a name can't start with $"], "locations": []}for a check,subjects.$foofor a subject,subjects.deployment.has.$foofor a part andrequirement namefor a requirement, and for an option it saysoption $foo: a name can't start with $under the check. A check with such a name has no entry inrequirements. Aforalias 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 isa 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, likex 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, witha name can't be emptyin the same places, as names.empty says. A requirement whosesubjectis""getsempty or not a stringundersubject. - So is a requirement without a
subject, or whosesubjectisn’t insubjects, like a typo fordeploymnt. The$well_formedrow says{"name": "subject", "value": ["missing"], "locations": []}or{"name": "subject", "value": ["not in subjects"], "locations": []}. Ifsubjectsitself 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
fromorrequires, because Ergo would otherwise ignore it and check something you didn’t mean.fromandidentified_bybelong to the subject. The$well_formedrow 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
checksisn’t an object, whosemin_subjectsisn’t a whole number from 0 to 1,000,000, whosesubjectisn’t a string with something besides whitespace in it, since rows and descriptions name the subject by it, whosedescriptionisn’t a string, or whosemetaisn’t an object.nullcounts as the wrong type, except fordescriptionandmeta: Ergo only copies those, so anullone counts as not written, which is what YAML gives for an emptydescription:.2.0is a whole number, but-1and0.5aren’t, and1000001is too big. It still has its entry inrequirementsand its$well_formedrow, 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
| subject | check | passed | cause |
|---|---|---|---|
a1 | reviewed | true | satisfied |
a2 | reviewed | false | value |
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
| subject | check | passed | cause |
|---|---|---|---|
7 | approved | true | satisfied |
8 | approved | false | absent |
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
| subject | in | check | passed | cause |
|---|---|---|---|---|
7 | commit: a1 | approved | true | satisfied |
8 | commit: a1 | approved | false | absent |
9 | commit: a2 | approved | false | absent |
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_bypicks is how a row is found again, and how two reports are compared. - How many lists sit between the subject and the values compared?
everyandsomewalk them all, but a check that compares fields at two levels needs aforline, 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
requirestill say what the sentence says? “Every commit has some approved pull request” iseveryover commits withsomeinside a check. With the pull request as a subject read from the input it becomessomeover 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:
| Sentence | Runs as |
|---|---|
env is "prod", also equals and is equal to | equals |
digest equals fingerprint, a field on the right | compare with eq |
cost is 2.5 within 0.01, cost equals total within 0.01 | compare with eq and within |
env is not "prod", author does not equal approver | compare with ne, so two values of different types fail as unusable |
size is not 100 within 5, size does not equal limit within 5 | compare with ne and within |
env is one of "dev", "test", user is in $params.staff | in |
env is not one of "prod", user is not in $params.bots | not_in |
ticket exists, ticket does not exist | present, missing |
approved_by is a non-empty string | non_empty_string |
approved_by is not a non-empty string | not_non_empty_string |
tags is a list, config is an object | has_type: list, string, number, boolean or object |
tags is not a list, config is not an object | lacks_type |
findings is empty, tickets is not empty | empty_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 suffix | compare with starts_with, ends_with |
branch does not start with "wip/", file does not end with suffix | compare with not_starts_with, not_ends_with |
tested_by is in reviewers, files contains all of gap_files | compare with in, includes_all, matches_any, … |
score is at least 3, is at most, is more than, is less than | compare with gte, lte, gt, lt, on numbers only |
name is alphabetically before "m", is alphabetically after | compare with alphabetically_before, alphabetically_after |
name is alphabetically on or before "m", is alphabetically on or after | compare with alphabetically_lte, alphabetically_gte |
score is between 1 and 5 | range |
score is not between 1 and 5 | outside_range |
reviewed_at is before deployed_at, is after, is on or after, is on or before | compare_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:
| check | cli fails as | because |
|---|---|---|
not_a_snapshot | unusable | version is a number |
not_near_limit | absent | there is no size_mb |
trusted_license | null | licenses is null |
notes_empty | null | notes 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"}
}
inputsnames the path with[]after each list walked, and holds the value each item’s path ended at.- Each entry in
failed_itemsgives the field that failed aspath, and the item it belongs to asitem, 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 awhosethat 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, likehiforwhose lo is at least hion{"lo": 1}, withvaluenull. A field that’snullcounts as not read, and so does the list of acount ofthat isn’t a list. distinctaftereveryorsomedrops an item whose value is the same as an earlier one’s:every distinct commits.author is "ann"on commits byann,bobandbobfails with one entry infailed_items, foritem 2 of commits, whoselocationsare/commits/1/authorand/commits/2/author. The row’sinputsstill list all three authors. An item awhosecan’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 lopasses, withloread as the subject’s 3. Awhosereads the item before copies are dropped. Aforline takes it too, likefor: every distinct commits.author as author: the alias stands for each different author once, and the entry of a failed one lists every copy.distinctgoes right afterevery,some,count oforsum of, and anywhere else it is written wrong withdistinct goes after every, some, count of or sum of. It is a word of the grammar, so a field calleddistinctis written.distinct, a subject calleddistinctis written"distinct", and a baredistinct, likedistinct is 1,x is distinctorfrom: every xs whose distinct is 1, is written wrong withdistinct is a word of the grammar: write .distinct. Under aforline 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 theforline’s problem.- An empty list fails
everyasvalue, 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. whosekeeps the items that pass its conditions, read on the value the path ends at:some pull_requests.approvers whose state is "APPROVED" existspasses above, withbob’s approval. Socount of pull_requests.approvers.username whose state is "APPROVED" is 1readsstateon each username, and fails asnot_an_objectwithitem 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 aforline, 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
everyorsome, a field on the right of the phrase is the subject’s, sosome approvers.timestamp is after last_commitcompares each approver’stimestampwith the subject’slast_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 afteris at least, the message says so once, likelast_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.namefails asunusablewithitem 2 of $params.envs is null, which isn't an object, and names no item ofxs. 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 aforline, below. Aforalias is read on the right of awhosetoo,count ofandsum ofincluded, so withfor: every groups as g,count of rows whose n is count of g is 1counts the groupg, and.greads each row’s owng. A child subject’s parent works the same way: with the parentfam,count of rows whose n is count of fam.z is 1counts the parent’sz,count of rows whose count of fam.z is 1 is 3counts each row’s ownfam.z, and.fam.zon the right reads the row’s. A parent whosezis missing,nullor not a list fails the check asabsent,nullorunusable. every,some,count ofandsum ofpaths can be ten steps long, wherever they stand, on the right of a phrase too. Aforalias and a position likefirst ofeach count as a step, and$inputand$paramsdon’t. A walk that starts at a part counts the part’s own path instead of its alias, so withhas: {"x": "k1.k2.k3.k4.k5.k6.k7.k8.k9"},every x.z is 1is ten steps, and a part of ten names makes it eleven. A part read without a walk, likex.z is 1, has no limit. An item picked on aforline, likefirst of reviews as review, takes ten names, not counting its position (sentence.walk.steps).containsand the other list phrases can’t followeveryorsome, because the last list is walked too, soevery pull_requests.labels does not contain "hotfix"would ask each label. It’s written wrong, and the message says to put the walk on aforline.
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_itemslists the items of the first list that failed, each with what its alias stands for as itsvalue. Each one hasfailed_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. Hereitem 1 of pr.approversis pull request 8’s first approver. Withfor: some approvers as approver, every commits as commit, an approver who approved before the last commit failsapprover.timestamp is after commit.timestampwith"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 notimestampwould be listed there with"cause": "absent". A sentence that doesn’t readcommit, likeapprover.username does not equal author, has noitems, 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 theinputsof a row of its own, with the names as the sentence writes them and where each value was read inlocations, see where a value was read. An entry ofitemshas the inputs for that pair of items. A sentence with noitemshas 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 readapprover.usernameandpr.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 notimestamp, shows"value": nulland the location where it stopped, and acount ofon an empty list gives the count0, 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 commitwithapprover.timestamp is after commit.timestamp, the first entry offailed_itemsis:{"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 itspart. Only the sentences of aforline have them: thefailed_assertionsof a check with several sentences and noforline have none, because its row’sinputsalready hold the values.failed_assertionsis[]when no sentence was checked, like whenwhoseleaves no approver, or a field awhosereads 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. Withfor: some approvers as approver, every commits whose author does not equal approver.username as commitand a commit with noauthor,{"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_itemsinstead, in place of the approvers that failed only because of it. That’s a missingcommitslist,{"cause": "absent", "failed_assertions": [], "item": "commits", "locations": ["/releases/0"], "path": "commits", "value": null}, or a commit thatwhose kind is "code"can’t decide, because thatwhosedoesn’t readapprover. An approver that’s still listed has the cause of what’s left under it. A list inside the first, likepr.approvers, is listed under each pull request, because each has its own.inputsholds each list theforline walked, with its items, and every field of the subject or of a parent the sentences read, liket.x. A second list that starts at the first item’s alias, likez.subsinevery items whose k is 1 as z, every z.subs as y, shows only the items under the first items thatwhosekept. The first list still shows every item, the oneswhoseleft out too. A field read through aforalias, likepr.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 ownwhose, 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 afterwhose:whose env is "prod"always reads the item’senv, and so doeswhose 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
everyorsomedoes: empty, missing,nullor 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,
$paramsor$input, with or without a path after it, and an alias from an earlier line. When a compare oris inphrase reads it as a value, likezinm.v is at least z,$params.qinm.v is in $params.qorzinz is 1, whencount oforsum ofreads it, likecount of z, and when it is anamed bykey, likezinm.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. Withzholding"s",z is 1saysz is "s", which isn't a numberandcount of z is 1saysz is "s", which isn't a list or an object, withzholding{"a": 1},count of z is 1saysz is {"a": 1}, which isn't a list, and withzholding3,m.t named by z is 1saysz 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, withzbholding"s"andzaholding3, saysza 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 zbwithzanullandzbholding"s"sayszb is "s", which isn't a number; za is null, becauseunusableis worse thannull, and the twocount ofnames above tie, sozacomes first. A value of the right type that doesn’t match, like3inz is 1, is no problem with reading it. A name read any other way, likeevery zorz exists, is read for each item, so on an empty list the check says onlyxs is []. A name that depends on the item, the line’s own alias or anamed bykey read from the item, keeps the item in front, because it can differ for each item. With the subjectprfromprs, the params{"q": "s"}and this input:{"prs": [{"id": "p0", "z": "s", "xs": []}]}for: every xs as mwithassert: m.v is at least $params.qfails asunusablewith$params.q is "s", which isn't a number; xs is []: the name first, then the empty list. Writtenevery xs, if any, as m, it says only$params.q is "s", which isn't a number. Withxsholding[{"v": 1}, {"v": 2}]it says the same once, andfailed_itemslists both items, eachunusable. When an item also fails on its own, its own problems follow the names, without them:for: every xs as mwith the sentencesm.v is 2andm.v is at least z, andzmissing, on[{"v": 1}, {"v": 3}]saysz 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. Withzholding"s"andxsholding[{"v": null}], it saysz is "s", which isn't a number; item 1 of xs: m.v is nulland fails asunusable, the worse ofunusableandnull. A name that is only$paramsor$inputcounts too:for: every xs as mwith$params is 3and the paramsnullsays$params is missing; xs is []on[]. A root in awhoseon theforline 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 mwithpr.zholding"s"sayspr.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: withhas: {pl: c}and the input{"prs": [{"id": "p0", "c": [{"z": 1}], "xs": [{"v": 1}]}]},m.v is in pl.zfails asunusablewithpl is [{"z": 1}], which isn't an object, aspl.z is 1does, and so doescount of xs whose v is in pl.z is 0. With params that aren’t an object, like3,for: every xs as mwithm.v is at least 1on{"prs": [{"id": "p0", "xs": ["s", {"v": "x"}]}]}still fails asunusable, where the item alone would make itnot_an_object, and saysitem 1 of xs is "s", which isn't an object; item 2 of xs: v is "x", which isn't a number. Onlyambiguousranks aboveunusablethere:for: every xs as p, first of xs whose k is p.k sorted by v as mwithm.v is 1, params3and two items that tie onvsaysitem 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 vand fails asambiguous, and withm.v is at least $params.nit says$params is 3, which isn't an object;before the tie.Two items at most.
named byan alias from theforline reads the key its item holds. Withfor: every personas_dispatched as persona,persona_blob_shas named by persona existsreadspersona_blob_shas.security, thenpersona_blob_shas.style, and so on. An item that’snullfails asnull, and any other item that isn’t a string, a number included, asunusable, since it can’t be a key.m.k named by m.k is 1on{"prs": [{"id": "p0", "xs": [null, {"k": "a"}]}]}saysitem 1 of xs: m is null; item 2 of xs: m is {"k": "a"}, which isn't text. The row’s inputs showpersona_blob_shasitself.
With for: some approvals.approver as approver, where a commit also has an approver field:
| sentence | compares |
|---|---|
every commits.author does not equal approver | each commit’s author with the name approver |
every commits.author does not equal .approver | each commit’s author with its own approver |
.approver equals approver | the 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
expressionsays how the item was picked,for last of commits sorted by timestamp as last_commit, โฆ, andinputssays which item that was in the input,item 1 of commits. first of commits sorted by timestampis the earliest,last ofthe latest, anditem 2 from last ofthe second latest.- A
whosegoes beforesorted by, and keeps the items to sort:last of approvers whose state is "APPROVED" sorted by timestamp as approvalis the latest approval. An item thewhosecan’t decide fails the check with its cause. sorted bysorts numbers, and RFC 3339 times by the moment they name, so2026-10-02T01:00:00+02:00comes before2026-10-02T00:00:00Z. Dates like2026-10-02are sorted by day. An item whose field is missing,nullor anything else fails the check asabsent,nullorunusable, 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 asunusable, and with times and dates the dates fail.sorted by name alphabeticallysorts 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 ofanditem 2 from last ofcount 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 at1760000200above,failed_itemsholdsitem 1 of commitsanditem 2 of commits, each with thetimestampasvalue. 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 byreads a field of the item, so it can’t start with$paramsor$input. A field with the same name as a name from theforline needs a dot in front,sorted by .c. It can’t go aftereveryorsome, 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 qgives eachpthe firstywith the samek.inputslists each item picked, likeitem 2 of ys. A first item for which nothing is picked isn’t listed infailed_items, and aysthat 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_usernamewould be the last of a list atcommits.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:
| check | inputs |
|---|---|
some_persona | count of personas_ran = 1, personas_ran[] = ["security"] |
claude_findings | count of findings whose source is "claude" = 2, findings[] = [...], claude_found = 2 |
costs_add_up | sum of stages.usd that is more than 0 = 0.75, stages[].usd = [0.5, 0.25, 0], total_usd = 0.76 |
all_findings | count 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 ofwalks every list along its path, aseverydoes, socount of pull_requests.commitscounts the commits of every pull request. An empty list adds nothing, socount of approversis 0 when there are none.- A list that’s missing,
nullor not a list fails the check asabsent,nullorunusable, and so does a number to add that’s missing,nullor isn’t a number. - An item that
whosecan’t decide about fails the check with thewhose’s own cause, as withevery:count of items whose k is "a"on an item withk: nullfails asnull. Asum ofadds numbers, which have no fields, so awhosethat reads a name of the value it adds is written wrong:sum of items.x whose k is "a" is 1sayswhose k: sum of adds up numbers, which have no fields: to keep numbers by their value, write that instead of whose. Writethatto keep numbers by their value, likesum of items.x that is at least 1 is 3. The right side ofthatcan’t read a field of the number either, so with the subjectpr,sum of items.x that equals k is 1saysthat 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 orpr.kthere is fine. Anamed bykey read against the number is written wrong the same way:sum of items.x that equals $params.m named by k is 1namesk. A name awhosereads from a root, like$params.qincount of items whose k equals $params.q is 0, is read once, up front, so when it’s missing the check fails asabsentwith$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, socount of xs whose v is in pr.z is 0withpr.z"s"fails asunusablewithpr.z is "s", which isn't a listwhenxsis[]. 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 1with neither there says$params.q is missing; pr.z is missing, and with$params.qnullinstead it says$params.q is null; pr.z is missing. The same goes for a name on the right of the phrase aftereveryorsome, and for awhoseon aforitem. In acount oforsum of, a$paramsor$inputname that is missing ornullcomes 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 0with 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 numberforcount of xs whose v is at least pr.z is 0, and the same forstarts withandis before. A name in a sentence under aforline is named once, with no item. whosegoes after the lastcount oforsum 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 4listscount of approvalsandapprovals[]one time each. distinctaftercount oforsum oftakes each value once: on commits byann,annandbob,count of distinct commits.author is at least 2passes, and its row listscount of distinct commits.authorwith2andcommits[].authorwith all three names.sum of distinct stages.usdadds each different amount once, so two stages that both cost1.5add1.5. With awhose, only the items it keeps are counted. Anullauthor counts as one more value, as it does forcount of. To leave it out, keep only the commits whose author exists: on commits byann,null,bobandann,count of distinct commits.authoris 3, andcount of distinct commits.author that existsis 2.- Numbers are added as decimals, so
0.1plus0.2is0.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.50has 1,1e2has 0 and1e-7has 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 asunusableand the number isnull. So the sum has to fit in 15 digits at one scale. That’s stricter than 15 significant digits for big round numbers, because1e20plus1e20fails 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 betweenandwithin.
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.5days and one second is0.000011days. That way every runtime gives the same number. There’s nomonthsoryears, because they have no fixed length. - Both fields must be RFC 3339 times, like
2026-10-01T00:00:00Z(a capitalTandZ, a real date, a year from 1678 to 2261), both dates, like2026-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 asunusableinstead of being read as seconds. A missing field fails the check asabsent, anullone asnull, and anything else, including one time and one number, asunusable. - When the second time is before the first, the check fails as
value, and the number in its first input isnull. - It goes where a count goes: at the start of a sentence with the phrases a count goes with, as a term of
plusand on the right side of a phrase, whereisprints asequals, as it does before a count. It also goes in thewhoseof acount of, asum ofor anevery, where an item whose end is before its start is left out.every,someand afromline are written wrong withisn't supported yet. It works in each line underassert, and under aforline, where a path can start at a name, likedays elapsed from pr.opened_ts to pr.merged_ts is at most 2. seconds,minutes,hours,days,weeksandtoare 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:
| word | example | in focus |
|---|---|---|
every, some | every approvers.username โฆ | each username |
count of, sum of | count of approvers โฆ | each approver |
distinct after one of those | every distinct xs.b โฆ | each different b |
a for item (every, some, a position) | for: every approvers as approver | each approver, named approver |
a from line | from: deployments whose โฆ | each deployment (each candidate subject) |
| one of these inside a relative clause | xs whose count of ys.k is 1 | each k of each x’s ys, inside the focus |
Relative clauses, where names resolve against the focus:
| word | example | resolves |
|---|---|---|
whose | approvers whose state is "APPROVED" | state: each approver’s |
| its right side | deployments whose finished_at is after started_at | started_at: each deployment’s |
that | ws that starts with "b" | each w itself |
and whose, and that | โฆ and whose username โฆ | a new relative clause, same focus |
named by <key> inside one | xs whose t named by k is 1 | k: each x’s |
sorted by (not a relative clause) | last of commits sorted by timestamp | timestamp: each commit’s |
Everywhere else, names resolve from their root:
| place | example | resolves |
|---|---|---|
| the start of an assertion | author is "ann" | the subject’s author |
the phrase after every, count of โฆ | every approvers.username does not equal author | author: the subject’s |
and followed by a path or every โฆ | โฆ and author is "ann" | a new assertion from the root |
lines under a for | approver.username is in $params.staff | approver: the alias; bare names: the subject’s |
plus terms, followed by pieces, elapsed from โฆ to โฆ | x is "v" followed by tag | wherever 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:
| name | set up by | example | root |
|---|---|---|---|
$input, $params | nothing: always there | author is in $params.staff | the input, the params |
a for alias | for: every approvers as approver | approver.username is in $params.staff | each approver of the subject |
| a part | subject pr with has: {coverage: attestations.coverage} | coverage.pct is at least 80 | the subject’s attestations.coverage, so it reads attestations.coverage.pct |
| a parent subject’s name | subject pr with from: every artifact.pull_requests, so artifact is its parent | base equals artifact.branch | the artifact this pull request was read from |
| the subject’s name | the requirement’s subject: pr | pr is an object, and approvers whose username does not equal pr.author | the subject itself. pr.author only inside a relative clause |
| a leading dot | a 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 byafter the name, likepr.author,pr[0].yorpr 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, soregion.namemeans nothing and is written wrong, and so areregion[0],region."x",region named by $params.kandfirst of region.name. With the subjectliband the checklib.name is 1, the$well_formedrow sayslib.name: nothing can follow lib, which means the subject: for a field called lib, write .lib.name. Brackets straight after the name, likelib[0], are written wrong in the same way, withwrite .lib[0]. To read one item of a subject that is a list, write the position in words,first of liboritem 2 of lib. The name can’t be the key of anamed byeither:$params.m named by lib is 1saysnamed by lib: lib is the whole subject, so it can't name a key. Inside a relative clause, afterwhoseorthat, a bare word is the item’s field, so therelib.namereads the subject’sname: with the subjectpr,count of approvers whose username does not equal pr.author is 1counts the approvers who aren’t the pull request’s author. Inpr named by kthere,kis each approver’s own field, and an approver without one fails asabsent, with the approver named, likeitem 2 of approvers: k is missing. To take the key from the subject, write it with the subject’s name after any path, likepr named by pr.kor$params.m named by pr.k: the key is the pull request’skwhatever the approver holds, and when the pull request has nokthe check fails asabsentwithpr.k is missing, once. A name read from a root inside a relative clause, likepr.a.b,$params.a.bor 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
.nameis the field. With the subjectbuildfrombuildsand the input{"builds": [{"id": "b1", "build": "x"}, {"id": "b2", "build": "y"}]},.build is "x"reads each item’sbuildfield andbuild is an objectreads the item. The row forb2says.build is "y", with the input named.build, and the row ofbuild is an objectnames its inputbuild. - On the right side it reads the subject too. With the subject
libfromevery libs,some $input.deps whose name is lib existskeeps the dependencies whosenameis the library. With the input{"libs": ["a", "b"], "deps": [{"name": "a"}]},apasses, andbfails 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,.libon the right of awhoseis the item’s own field:some $input.deps whose name is .lib existskeeps the dependencies whosenameequals their ownlib. - 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 calleditem, reads the subject too:"item" is a stringon{"items": ["a", 2]}passes foraand fails for2withitem 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:
status | When |
|---|---|
met | the requirement passed, and at least one subject was left to check |
not_met | anything else: a check failed, too few subjects were left, a condition couldn’t be read, or the requirement is written wrong |
not_applicable | the 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"andx == ""compare against strings, andn == 1,ok == trueandx == nulldon’t. Only",\and control characters are escaped, as\",\\,\b,\f,\n,\r,\tor\u0001and 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,1e2and2.5e-3are shown as1,1.5,100and0.0025, and-0as0. 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, turns12345678901234567890into12345678901234567000before Ergo sees it. A number in the policy has to be0or have a magnitude between2.2250738585072014e-308and1.7976931348623157e308, the range those numbers hold without losing digits. Elsewhere, a language like JavaScript turns1e400intoInfinityand1e-400into0, 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 infrom,identified_byormin_subjectsfails$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, so1e400equals1e400and1e-400doesn’t equal0, 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.
| Cause | Message | Example |
|---|---|---|
value | each value compared, joined with , and | x is 4 |
absent | the inputs that are missing | gone is missing |
null | the inputs that are null | n is null |
unusable | the value, and the kind it isn’t | s is "3", which isn't a number |
not_an_object | the subject, by its name | deployment is "str", which isn't an object |
ambiguous | the items that tie, and the key they tie on | item 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, likel is ["a", "b", "c", and 2 more]. - An object whose JSON is over 80 characters is printed as
an object with N keys, likeo is an object with 4 keys, which isn't text, oran object with 1 key. - A line separator inside text (U+2028, U+2029 or U+0085) is written as
\u2028,\u2029or\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$refsshows. - A read that stops early points at the last value it reached.
d-2has noregion, 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
inputsof anevery, has one location for each, in the same order. An entry that holds a number Ergo worked out, likecount of findings, lists the locations of what it was worked out from. - A failed item points at where its
valuewas read, in thefailed_itemsof an$appliesrow as in those of a check. Thefailed_assertionsof an$appliesrow or of a check without aforline hold no values, so they have no locations. Those of aforline haveinputs, with locations of their own. - The entries of
$well_formedare about how the policy is written, not about the input, so theirlocationsare[]. 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.xand a child’s own.b.xcan 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:
descriptionis the subject’s, or""when it has none. Adescriptionthat isn’t a string is written wrong and shows as"".fromsays how the subject is found, as a from line is written. Afromwritten as a line is printed the same way whatever spaces it had. A subject withoutfromreads the input field named like it. Afromthat 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, likevalues of mixed types, and thefromof a subject withapplies_toorof, because those fields said how it was found.identified_byis the path of the subject’s id, ornullwhen 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 itsfromis written wrong. An id that is written wrong shows as<invalid identified_by>.partslists every part the subject has, including the ones it keeps from a subject it narrows, by alias. Each is{"from": ..., "identified_by": ...}, withfromwritten the way a sentence writes a path, so" dd.b "isdd.bandx[$params.k]isx named by $params.k, andidentified_bythe path of the part’s id ornullwhen 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.
| Check | One row per | Passes when |
|---|---|---|
$well_formed | requirement | the 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_subjects | requirement | from 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_ids | requirement | no two subjects share an id, counting the ones its conditions leave out. |
$unique_ancestor_ids | ancestor | no 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. |
$applies | subject | the 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": [] }
]
| Field | What’s wrong |
|---|---|
| any field Ergo doesn’t know | unknown field |
checks | not 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 name | a name can't be empty |
require | neither every nor some |
min_subjects | not a whole number from 0 to 1000000, number out of range |
subject | missing, empty or not a string, not in subjects |
subjects | not an object |
subjects.deployment | not an object |
subjects.deployment.from, ...identified_by | not 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.from | leads to a loop, " " isn't a subject name |
subjects.deployment.description | not a string |
subjects.deployment.has | not an object |
subjects.deployment.has.coverage | not 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:
- all the
$well_formedrows, then all the$min_subjectsrows, then all the$unique_idsrows, then all the$unique_ancestor_idsrows, outermost ancestor first, then all the$appliesrows, then your own checks - within each group, requirements in name order
- within a requirement, subjects in the order they appear in the input, or in key order when
named by each ofreads an object - 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.
cause | Meaning |
|---|---|
satisfied | The check passed. |
not_an_object | The subject isn’t an object, so it has no fields. |
ambiguous | Another item ties with the one a for line picks sorted by a field. |
unusable | A 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. |
absent | A field the check reads isn’t there. |
null | A 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. |
value | Everything 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,someandforlines, a list that isn’t there givesabsent, and one that isn’t a list givesunusable. An empty list givesvalue, 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,nullincluded, fails asnot_an_objectwhen the path goes on into it: withtholding["a"]or[null],every t.k is 1and aforlineevery t.k as xboth failitem 1 of tthat way, and so docount of t.kandsum 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 nousername, a check that some approver isn’t a bot fails asabsent, because the second one might not be. Anany_ofworks the same way across its options, and so does a path through several lists. $well_formedand$min_subjectsdon’t read the subject, so their cause issatisfiedorvalue, except that$min_subjectsfails asabsent,nullorunusablewhen a$paramsor$inputpath afternamed byinfromcan’t be read, or whenfromdoesn’t lead to a list or an object.$appliesreports the state of the fields read by the conditions that failed. Itsinputslist each field its conditions read once, sorted by name, so two conditions onstatusshowstatusonce. For example, a subject whose condition field is missing saysabsent, and that fails the requirement. A condition that’s written wrong makes the requirement written wrong, so it gets no$appliesrows 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
$appliesrows with the causevalue, since being out of scope isn’t a problem. An$appliesrow that failed because a condition couldn’t be read is kept.- every row of a requirement whose
statusismetornot_applicable. Underrequire: "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,
nullor wrong-typed field fails every operator, exceptmissing, which passes on a missing ornullfield. compareneeds both sides to exist and have the same type. In plain Rego,null < 5is true, so a missing field would otherwise pass altcheck. Ordering objects, lists or booleans fails too.every,someandforlines 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,
nullor 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_formedand 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
rangeneeds a number, or a param of the wrong type, fails with causeunusable, 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
existsthat found its field missing. min_subjectsis 1 unless you say otherwise, so finding nothing fails. Afromthat leads nowhere, tonullor to something that isn’t a list or an object fails even withmin_subjects: 0.- A key after
named by each ofthat 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
$paramsor$inputpath that can’t be read fails the check, even for phrases likedoes not containordoes not matchthat 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
$appliesrow and every$min_subjectsrow with causeunusable, so even a requirement with no subjects isn’t met.$min_subjectssays why in its inputs, like{"name": "$input", "value": ["holds a set"], "locations": []}, or$paramsfor 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_formedand keeps its place in the report, so a typo in the policy can’t make a requirement vanish. - A malformed timestamp fails
compare_timerather 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.