Repository navigation
core: write every selector as a string and every check as a sentence #174
Description
We want control owners and auditors to read ergo policies, not just engineers. Today a check is an op, a path written as a list of segments, and a value. That reads like a syntax tree. op says nothing about intent, from and path do the same job under two names, and a list of segments is how a program indexes a field, not how a person names one. #173 shows where this leads: a count, sum and where check is a nested tree in YAML, and the one-line expression ergo renders in the report is easier to read than the policy that produced it.
That's the tell. The report already has a readable grammar for checks. The policy should use it.
This issue proposes writing each check as a one-line sentence. The shape is heavily inspired by two existing syntaxes. rspec gives the structure: one expectation per line, written as a thing, a verb and a matcher, expect(x).to be_empty, with is in place of should. hyperscript gives the words: its comparison operators, is not empty, does not contain, is in, matches, starts with, are plain English phrases that each fit on one line, and the table under Borrowed parts maps them onto ergo's operators.
The skeleton around the sentence, the requirement, its subject, its filters and the names of its checks, stays as YAML and is being settled in #113. This one is only about how a single check or filter is written.
The proposal
The README example. Today:
subjects:
deployment:
from: [deployments]
id: [id]
applies_to:
is_prod:
op: equals
path: [environment]
value: prod
requirements:
prod_deploy:
description: Every production deployment is approved
subject: deployment
checks:
approved:
description: Someone approved the deployment
op: non_empty_string
path: [approved_by]Proposed:
subjects:
deployment:
from: deployments where environment is "prod"
id: id
requirements:
prod_deploy:
description: Every production deployment is approved
subject: deployment
checks:
approved:
description: Someone approved the deployment
assert: approved_by is not emptyA check without a description is just the sentence:
checks:
approved: approved_by is not emptyThe two cases that get ugly in today's shape. A list check, from the reference. Today:
signed:
op: all
path: [commits]
check: { op: equals, path: [signed], value: true }Proposed:
signed: every commits.signed is trueAnd a derived value, from #173. Today:
costs_add_up:
op: compare
left: { sum: [stages], each: [usd], where: { op: equals, path: [kind], value: model } }
right: [total_usd]
cmp: eq
within: 0.01Proposed:
costs_add_up: sum of stages.usd where kind is "model" equals total_usd within 0.01The sentence has a fixed shape: a path, then a phrase, then what comes after the phrase. The phrases come from hyperscript, see Borrowed parts below. The verb is is everywhere, in checks, filters and where alike. Filters and where conditions can only take is, so must in checks would mean two verbs for the same phrase. The assert key carries the "must": every line under it is a rule. In the corpus on the spec/sentences branch, 9 of 229 checks read a little better with must, all saying something bad is absent, like open_bump_prs is empty, and that's the price.
Selectors
Every path is a string, and the list form goes away: in checks, in from, in id, in params and in refs. The word every in front of a path walks into each list along it, so every commits.signed reads every commit's signed, and some, sum of and count of do the same. The last list is walked too, so over a list of strings, every artifact.developers is not "bob" checks each name. To compare each string with a field of the subject, use a for line, since in a one-line every a bare name on the right side is the item's. developers does not contain tested_by looks like the same check without the for, but it passes an empty list, which every fails, so it isn't. where filters the items a quantifier walks: its conditions are about the item, and the assertion at the end of the sentence is about the field the path ends at. So in some approvers.username where state is "APPROVED" is in $params.internal_staff, state is read on each approver, and username is what's compared. The report prints paths the same way, so the policy and the report use one syntax for the same thing, and a path from a row can be pasted back into a policy.
every over an empty list fails with cause value, as all does today: no commits isn't proof that every commit is signed. When no items is fine, the sentence says so with if any after the path, every review.incidents.action_ticket, if any, is not empty, which replaces today's any_of with an empty escape. It sits after the path and not at the end because a value can contain commas and a path can't. A list that's missing, null or not a list fails as absent, null or unusable with or without it. Over nested lists it covers every list the sentence walks. With some it's written wrong, since "some approver, if any" would let an empty list pass.
What the list form was buying, and what replaces it:
| list form today | string form |
|---|---|
["server", "allowed_regions"] |
server.allowed_regions |
| a key with a dot in it | server."my.key", quoted like jq. A bare key can hold letters, digits, _ and -, so Kosli's secrets-scan and pull-request need no quotes. The report prints keys by the same rule, so a key like foo$bar prints quoted there too, and $ only ever starts $params or $input |
0 picks an item, "0" picks a key |
first of items, last of items and item 3 of items pick an item, and items."0" picks a key. items[2] is accepted too, as the engineer's spelling of item 3 of items: the word form counts from 1 and the bracket form from 0, the report prints the word form, and item 0 of items is written wrong with a message that says so |
a key starting with $, today {"literal": "$schema"} |
"$schema" quoted |
["$$params", "min_approvers"] |
$params.min_approvers |
{"ref": ["$$params", "repo"]} as a step |
repos named by $params.repo, or repos[$params.repo], see Looking up by key |
{"each_as": "$pr"} naming step in from |
every pull_requests as pr in a for line, then pr.author, see Two quantifiers |
["pull_requests"] then "each": ["commits"] |
every pull_requests.commits |
$ is reserved for the two things no policy declares: $params and $input. Today's $$ becomes one $, and a $word other than those is written wrong. A name given by as has no $. It's declared in the check that uses it, so the reader sees where it comes from, and for the rest of that check it shadows a field of the same name: with as pr, pr.author is the named item's, even when the subject has a field called pr. Pick another name if you need that field. A name can't be params, input or a word of the grammar like every, which is written wrong.
Two things to settle when it's built:
- Parse each path once. In
opa evalfunction calls are cached, but in Wasm they aren't, so a path parsed again for every subject and check would cost memory. The parsed paths belong in a rule keyed by requirement and check, built from the policy alone, so they're computed once per report. fromtakes the same shape as aforitem, soapplies_togoes, see Subjects.
Labels on what a selector reads (#34) are open and should be answered in this syntax.
Looking up by key
Reading a field whose name comes from a parameter. The corpus has 44 of them, almost all in from, picking one artifact out of a Kosli trail, and 32 go on after the key. Today it's a ref step in the middle of a path list.
| picks | word form | bracket form |
|---|---|---|
| an entry by key | artifacts_statuses named by $params.artifact_name |
artifacts_statuses[$params.artifact_name] |
several entries by key, today's keys |
attestations_statuses named by each of $params.test_attestation_names |
none |
an entry by a name from a for line |
persona_blob_shas named by persona |
persona_blob_shas[persona] |
| an item by position | item 3 of items |
items[2] |
- Both spellings are accepted, and the path can go on after either:
artifacts_statuses named by $params.artifact_name.attestations_statuses. The report prints the word form. Every one of the 44 was written by a Kosli engineer and is read by auditors in reports, so engineers write brackets and auditors read words. The readability test in the plan decides whether the word form is right for readers. - In brackets the type decides, as in JSON itself: a string picks a key, a number picks a position. Positions count from 1 in words and from 0 in brackets, as agreed above, and with a parameter that's less visible:
item $params.n of itemsanditems[$params.n]differ by one. - There's no bracket form for several keys, because
a[x]picks one thing in every language, and a parameter meant to be one name that arrives as a list would quietly turn one subject into several instead of failing.named by each ofsays on the line that a list is expected. - A key from a
forname is what pr-reviewer'skeys_matchneeds. Today a ref can't start with a name, so it's a custom operator. Withfor, it becomes a sentence.
Literal or field
Today the type does the work: value: prod is a literal and right: [total_usd] is a path, and that's why equals and compare are two operators. In a sentence, a value is written the way JSON writes a scalar: is "prod" is text, is 5 is a number, is true, is null, and a bare total_usd is a field. A list is values separated by commas with no brackets, is one of "staging", "prod". Quoting and escapes inside a string follow JSON. A string can also be in single quotes, which keeps backslashes as written, so a regular expression doesn't need them doubled: matches '^\s*FROM'.
The left side of a sentence is always a path, so it needs no rule. The right side can be a value or a path, and the corpus found that 215 of its 390 lines could be read two ways there until these rules were added:
- A quoted string on the right side is text.
is "prod"compares with the text prod, never with a field called"prod". - Words of the grammar, like
empty,true,not,countandone, are never a field name on the right side.is emptyis the phrase,is trueis the value. - A field with one of those names is written with a dot in front on the right side:
is .empty,is .true. The dot is allowed on any right-side field,is .total_usdmeansis total_usd, and the report prints it only where it's needed. - The reference after
named byis$paramsor$inputplus one key, so the path can go on after it. pluscan't follow awhere, becausecount of x where n is m plus k is treads three ways. Put thewhereon the last term.
A normal sentence already follows these, so most people never meet them.
Words, not symbols
The sentence is for people who don't program, so it uses words where a language would use symbols. No braces, no ==, >=, && or !, brackets only as the alternative spelling of item 3 of items and repos named by $params.repo, and $ only to mark what the policy introduced, see Selectors. every, some, count of, sum of, where, is at least, is not do that work. The hyperscript table under Borrowed parts lists only the word forms for that reason, though hyperscript also has < and >. What stays is what prose already uses: quotes around text, dots between names, commas between values.
The grammar stays flat
Rego can't recurse, which is why #173 already has two layers: a basic one for plain assertions and a derived one that calls it once for where. The grammar pins the same limit on purpose:
- one assertion per line
- one
whereoreveryper sentence anddoes two jobs and nothing else: it joins conditions inside awhere, and it joins whole assertions, see Two quantifiers. Each side of anandis a complete, simple condition or assertion, so it's a flat list, not a tree- a
wherecondition can start withcount oforsum of, likewhere count of findings is at least 1, since that's still one condition with a phrase - no
or, no brackets, no mixing
Combinations stay in the structure, as they do now: any_of keeps its named options, and a check that needs two assertions lists two lines. That's also rspec's rule, one expectation per line. Every expression language gets asked for or and brackets. The answer is no, and the spec should say so. "Or" lives in the skeleton as any_of options, because each option has a name and the report says which one passed.
Two quantifiers
Some checks walk two lists, or compare an item with the thing it belongs to. DEV-0501 in the comments is one: every pull request needs some approver who isn't its author. Today that's an all with an any inside and an as to name the pull request, which is the one level of nesting Rego allows. One sentence can't hold two quantifiers, so the walking and naming go on a line of their own, and the assertion stays flat:
peer_approved:
description: Someone other than the author approved every pull request
for: every pull_requests as pr, some pr.approvers where state is "APPROVED" as approver
assert: approver.username is not pr.authorRead aloud: for every pull request, and some approver of it with state APPROVED, the approver isn't the author.
-
Each item in
foriseveryorsome, a path, an optionalwherewithand, andasa name. Items nest left to right, sopr.approverscan usepr. Two is the limit, the one level of nesting Rego gives today, and a third is written wrong. -
In
assertand in a later item offor, a bare name is the subject's field and a named item is reached by its name. Sostarted_atinfor: some approvals where role is "qa" as approvalwithassert: approval.approved_at is before started_atis the subject's. In a one-lineeveryorsome, every bare name is the item's, inwhereand on the right side alike, sosome approvers.timestamp where state is "APPROVED" is after last_commit_timestampreads both from each approver. The sentence ends atand. An assertion joined withandstarts from the subject again, exactly as a new line underassertdoes, so inevery items.price is at least 2 and owner is "x",owneris the subject's. To compare an item with the subject, use aforline. That's two scoping rules, and the spec has to state both. -
Fail-closed is unchanged.
someover an empty list fails,everyover one fails unless it saysif any, a missing or non-list path fails asabsentorunusable, and an item awherecan't decide fails the try with its cause. -
assertcan be a list, or one line withandbetween assertions. Both mean the same: each assertion starts from the subject, or from thefornames, whether it's on its own line or after anand. So a list and the joined line never read differently, and the report never prints a sentence the policy can't say. sdlc-policies 0007 needs it, an approver who approved, isn't the author and approved after every commit:peer_approval: description: A peer approved the pull request after its last commit for: some approvers where state is "APPROVED" as approver, every commits as commit assert: - approver.username is not author - approver.timestamp is after commit.timestamp
The report shows the list form whichever was written, and its
expressionjoins the lines withand. A failed row can then say which line failed. -
The one-line form stays for the common case.
every commits.signed is trueis the same check asfor: every commits as cwithassert: c.signed is true, and the report prints whichever was written. It's sugar, and it's what costs the second scoping rule. Whether it earns that is for the corpus to answer: if most quantified checks need aforline anyway, every walk should be one.
The word is for rather than map or with, because it reads as English in front of every and some and the clause walks lists rather than mapping anything.
Subjects
A subject declaration today holds a path to walk, filters and a name. That's a for item, word for word, hoisted to the top so several requirements can share it and the report can count it. So from takes the shape of a for item, and applies_to goes:
subjects:
emergency change:
from: deployments where environment is "prod" and change_type is "emergency"
id: id-
The
$appliesrow stays. Itsexpressionis the conditions as written,environment is "prod" and change_type is "emergency", and itsinputsshow what was read,change_type = "bug_fix", so a reader sees which condition failed. The rule everywherehas applies: a deployment with nochange_typefails the requirement asabsentrather than dropping out of it. What goes is the filter's name and its description. The condition is now readable, which the name was making up for. -
A subject can start from another subject, by that subject's name:
subjects: deployment: from: deployments where environment is "prod" id: id pull request: from: deployment.pull_requests id: number
A
pull requestis reached through adeployment, so its rows carry both ids,{"type": "pull request", "id": 8, "in": {"deployment": "d-1"}},min_subjectsapplies per parent, and a check on a pull request reads its deployment asdeployment.started_at, by the subject's name, with noasneeded. That's core: let from iterate at any step and name what it iterates over #52 and the parent subject in Tore's DEV-0801 comment, with nothing new in the grammar.A chain can also narrow instead of descending. Tore's DEV-0409 has a
critical defectsubject that is adefectwith two more conditions:from: defect where severity is "critical" and status is one of "open", "in_progress". Its rows are rows of the parent subject, with the same id, so there's noin. -
The ceiling is depth, and it's Rego's. Each level of walking is code in Rego, not recursion, so the chain from the input to the assertion, subjects plus
foritems, has a fixed limit. The corpus says what it is: the deepest real check walks three lists, two chained subjects then twoforitems with one list each, and that's also today's deepest check. So the spec names three list walks, counted as walks, not subjects. Acount oforsum ofis a walk too, in awhereas well, because it reads every item of its list just aseverydoes. Sofor: every pull_requests as prwithassert: count of pr.reviews is at least 2walks two lists. -
A subject's own name in its own
fromis the input field, never the subject, so sdlc-policies 0004 can keep a subject calledartifactread frominput.artifact. Any other subject's name starts a chain, and$input.artifactreaches the field when a subject of that name exists. A subject with nofromat all reads the input field of its name, andidwithoutfromworks the same way:subjects: artifact: deployments: id: id
One spelling for the section, the map, where a simple subject is a name with nothing under it. A one-line
subjects: artifact, lockfilewas considered for the case where every subject is a plain field, and dropped: one subject in the whole corpus fits, and aby idsuffix doesn't change that, because the others read a field with another name or walk a lookup. It can come back if real policies turn out simpler than these. -
A subject whose name has a space can start a chain, quoted like any key with a space:
from: "production deployment".pull_requests. YAML won't take a value that starts with a quote and goes on, so that line is wrapped in single quotes, see Downsides. It's rare enough that the author takes the cost. -
The
subjectssection stays. The far end of this is marking the row level with anidon aforitem and having no section at all, but the section is what makes subject counts and reuse work, and chaining gives the reach without it.
This changes the subject declaration, which #113 owns. It's here because the vocabulary is this issue's, and the two can't be read apart any more.
Errors
The grammar is flat, so the parser fails in a slot, not with "syntax error". Each slot can say what it expected, what it found and where:
| sentence | $well_formed says |
|---|---|
approved_by is empy |
unknown phrase "is empy" at column 13, expected one of: is empty, is not empty, is one of, ... |
environment is "prod |
unclosed quote at column 16 |
every commits.signed |
nothing after the path: expected a phrase, like "is" or "exists" |
... equals total_usd within |
"within" needs a number after it |
some approvers, if any, is not empty |
if any can't go with some |
every pull_requests some approvers.state is "APPROVED" |
two quantifiers in one sentence: put them on a for line |
environment is prod parses, as a comparison with a field called prod, unless the subject has no such field. That's the one mistake the grammar can't catch, so the message for a missing field on the right side of is should suggest quotes.
Rego has indexof, substring and count on strings, so the column is computable and can go in the message. Today's messages are invalid cmp and unknown op nope, so this is more context, not less.
The report
The report's expression is ergo's own rendering today, like approved_by is a non-empty string or sum(stages[].usd where kind == "model") eq total_usd, within 0.01. Once the policy carries a sentence, a second rendering would be a second grammar for the same thing, so expression becomes the sentence.
Not copied verbatim, though. The report has to stay byte-identical when the same policy runs again, which is why ergo already writes 1.50 as 1.5. That's for the same policy, not for two policies that mean the same: A and B and B and A print in the order written, because and has no effect on what passes or on the cause, and sorting would have to apply to assert lists too and could never apply to for lines. A row's inputs stay sorted by name, as today, since they're readings and not a sentence. A sentence has more room to vary: 'prod' and "prod", two spaces for one, is equal to and equals, a."b" and a.b. So expression is the sentence printed back from its parsed form in one canonical spelling: single spaces, numbers in their plain form, one phrase per matcher. is, equals and is equal to mean the same, and the report prints is when the right side is a value, environment is "prod", and equals when it's a field, subject_digest equals fingerprint, so a field on the right side is visible as one. The negated form follows: is not before a value, does not equal before a field. is one of and is in mean the same too, and print as is one of before written values and is in before a path, is in $params.internal_staff. A string prints in single quotes when it holds a backslash and no single quote, so a regular expression stays readable, and in double quotes otherwise. For a well-written check that's byte-for-byte what the author typed. When it isn't, the report shows the canonical way to write it. The rule for the spec is that parsing the printed sentence gives the same parse as the original.
What changes:
expressionon each check and inviolationsis the canonical sentence, and today's renderings go away.- A row's
inputsname paths as strings already, today asstages[].usd. They'd use the same words as the policy,every stages.usd, so a row and the policy's selector spell the same thing, and a path from a row can be pasted back into a policy. For a one-line check the value recorded is what the sentence compared, soevery commits.signed is truerecords[true, false], andfailed_itemskeeps the whole items for context. Today anallrow records the whole list. - A check with a
forline renders itsexpressionas the two lines joined with a colon, in canonical form, the shape today's nested rendering already has. Itsinputsrecord the list the first quantifier walked, whole, andfailed_itemsthe items of that quantifier that failed, as today for a nested check. - Names from
asappear in the report underas, next to the position: an input is{"name": "pull_requests", "as": "pr", "value": [...]}and a failed item is{"as": "pr", "path": "pull_requests[1]", "cause": "value", "value": {...}}. The name ties the item to the policy's words, the position lets a tool find it in the input without the policy. Today a path keeps the name it starts with, like$pr.commits[1], which only resolves if you know what$prwas.asis left out where the policy gave no name. It isn't callednamebecauseinputsalready uses that for the path. failed_items.pathkeeps brackets,pull_requests[1], zero-based, even though the policy writesitem 2 of pull_requests. It's a locator for tools, like JSONPath, and it sits next to the item'svalue, so a person doesn't count. The report uses brackets there and nowhere else.- A check that's written wrong renders as the sentence plus the slot error, as under Errors.
- Your own
expressionon a check still wins when it's a string, as today. Custom operators have nothing for ergo to print, so that's where it's needed.
Why it fits ergo's constraints
- The parser needs nothing new. ergo already uses
regex.match,regex.find_all_string_submatch_n,trimandjson.unmarshal, and they pass the Wasm and JS runtime checks today. - It's easier to spec than the tree. A flat grammar fits in half a page, and a port in TypeScript or Python writes a small matcher for it. The tree needs a page per operator to say which keys go where.
- The policy and the report say the same thing. A control owner reads
approved_by is not emptyin the policy and sees the same line in the report, with the value read next to it. - Failing closed doesn't change. A sentence that doesn't parse fails
$well_formedand its rows fail asill_formed, exactly as a check that's written wrong does now.
Borrowed parts
None of the existing expression languages can be reused whole. CEL is the industry standard, but commits.all(c, c.signed == true) is programmer syntax, and it's a recursive grammar that Rego can't parse. JsonLogic, Spectral and Kyverno are trees, which is what we have. jq, JMESPath and JSONPath select data rather than assert about it. rspec and chai are libraries, not specs. So the sentence grammar is ours, but each of its three parts comes from somewhere:
- The field takes from JSONPath only its dotted names and quoted names for keys with dots. Its
[*]becomes the wordevery, and[2]becomesitem 3 of, withitems[2]still accepted. - The value is written as JSON writes a scalar: quoted text, a number,
true,falseornull. A list is values separated by commas, without JSON's brackets. - The phrases come from hyperscript, whose comparison operators are the best off-the-shelf list of English assertion phrases around, and every one of them is a flat pattern, which is what Rego can match:
| hyperscript | replaces in ergo today |
|---|---|
x is [not] [equal to] y |
equals, compare with eq and ne |
x is [not] empty |
empty, non_empty_string |
x exists / x does not exist |
present, missing |
x is [not] in y |
in |
x contains y / x does not contain y |
includes, excludes with one value |
x contains all of y, z / x contains none of y, z |
includes, excludes with values. contains "a", "b" alone would be ambiguous between all and any, so a list needs one of these |
x matches y / x does not match y |
matches_any, not_matches_any |
x starts with y, x ends with y |
nothing today, cheap to add |
x is [not] between a and b |
two compare checks |
x is [not] a string, x is a number |
the type checks |
... ignoring case |
nothing today, a modifier like within |
Hyperscript itself can't be reused: it's a whole scripting language that exists only as its JavaScript parser, with no grammar document and no second implementation. Three things follow from borrowing its phrases:
- Negation lives in the phrase,
does not contain,is not in, rather than in a separatenotslot. That reads better and parses as one pattern with an optional word. isis the verb, notmust, see The proposal. The key isassert, so the sentence states the fact:approved_by is not empty,environment is "prod",every commits.signed is true.- Strings can be single-quoted, as hyperscript's own examples write them, which is the fix for the YAML
:problem under Downsides.
What hyperscript doesn't have is ours to define: every, where, sum of, count of, within, and time comparisons. Those are is before, is after, is on or after and is on or before, which read better than hyperscript's precedes and follows, and better than is not before, a double negative the corpus used six times. Both sides are timestamps in the same format, and one that isn't RFC 3339 fails the check rather than being compared as text.
is empty and is not empty work on strings and lists alike: "" and [] are empty, a string with a character or a list with an item is not. That's wider than today's non_empty_string and empty, which each took one type. When the type matters, say it: is a non-empty string, or is a list as a second check.
In the spec, each phrase is listed as a plain pattern, one line per form: <path> is not empty, <path> contains <value>, with what each gap can hold. The grammar is about thirty such lines, each a fixed sequence of words with one or two gaps, so every implementation matches them its own way. In Rego that's a regex per phrase, matched with regex.find_all_string_submatch_n as today. No library or notation is needed, and the hard part of parsing, whether the right side is text, a number, a list or a field, is custom in any language anyway.
Downsides
- A JSON Schema for policies covers less, because the check is a string to it. Editor squiggles and did-you-mean come back with a small tokenizer in JavaScript, which the playground in site: add a playground that runs ergo in the browser #124 and an editor extension can share. Rego stays the source of truth for what's valid.
- YAML plain scalars can't hold
:or#, somessage matches "^fix: "needs the whole sentence quoted and the inner quotes escaped. Single quotes are allowed too,matches '^fix: ', which avoids most of it. A sentence that starts with a quoted key has the same problem,"my key" is not emptyorfrom: "production deployment".pull_requests, and the whole line goes in single quotes:from: '"production deployment".pull_requests'. A first key with a space or a dot is rare, so that's the author's cost. - Tools that build or edit policies, like a UI that builds a check from form fields, have to print and parse sentences. Same tokenizer, but it has to exist in every language that touches policies.
- Phrases are harder to change than keys. Adding a key to a tree is backwards compatible by default. Renaming a matcher phrase breaks every sentence that used it. New phrases are fine, changing old ones isn't, and once sdlc-policies and the server use them, each phrase is a commitment.
What this touches
- core: let compare work out counts and sums from the subject #173, merged, added
count,sum,addandwheretocompare. Its behaviour and fail-closed rules carry over to the sentences, and only its YAML surface would change. - markdown: add a compiler for policies written in markdown #18, policies in markdown, gets better. A document that embeds checks as sentences reads as prose, and the compiler only has to map headings and bullets to the skeleton rather than spell checks its own way.
- core: let a selector tag the values it reads with labels #34, selector labels, becomes a question of where a label goes in a sentence.
- site: add a playground that runs ergo in the browser #124, the playground, is where the tokenizer for editor errors would live.
- core: decide on the words ergo policies and reports use #113 owns the subject declaration, and Subjects changes it:
fromin the shape of aforitem, noapplies_to, subjects that chain. Tooky'sfield: { operator: value }and Peter'sselector/must/containwere the steps that led here. - core: let from iterate at any step and name what it iterates over #52,
fromiterating at any step, is answered by chained subjects.
Plan
- Done: the
spec/sentencesbranch rewrites every check in sdlc-policies, the server's policies, pr-reviewer, ergo's examples and the DEV controls as sentences, 229 checks and 74 filters, with a draft grammar and a findings file. Most of what's above came from it. - Hand one to someone who hasn't seen ergo and ask them to say in words what it checks. If they get it from the sentence alone, without the description, the grammar works. This also decides whether the report prints
named byor brackets. - Bring the branch in line with this issue, write the grammar into the spec, then build it in Rego behind the same
$well_formedandill_formedmechanics as today.
Say which phrases read wrong to you, and bring a policy that doesn't fit on one line if you have one.
Activity
[-]core: write checks as one-line sentences instead of op, path and value[/-][+]core: write every selector as a string and every check as a sentence[/+]spec: split what checks mean from how they're written
spec: add requirements, scope, statuses, row order and violations
spec: add requirements, scope, statuses, row order and violations
ToreMerkely commentedon Oct 9, 2026
somewith two conditions: DEV-0801DEV-0801: outsourced development is supervised. An internal employee must approve every pull request from an external contributor.
The check is "some approver is APPROVED and is internal staff". A sentence holds two conditions on an item:
wherekeeps the items that pass its condition, and the assertion is about one field of each. So this check is one line, with the skeleton unchanged. Per #174 (comment).Today
With sentences
What changes
somewalksapprovers,wherekeeps those whosestateis APPROVED, and the assertion is about each one'susername. At least one has to pass, asanydoes today.any_ofwith one option goes away. Today a single-optionany_ofis how two conditions are joined withand. The sentence puts one inwhereand one in the assertion.approvers.usernamenames what the assertion is about.where state is "APPROVED"applies to the approver, not to the username. That is the reading to learn.wheretakesandas a flat list:some approvals.approved_at where role is "qa" and decision is "approved" is before $deploy.started_at(DEV-0502). No brackets, no precedence.orstays inany_of, because each option has a name and the report says which one passed.$params. A bare word is a field of the data.$params,$inputand a name fromascarry a$, so a field calledparamscannot be mistaken for them.min_subjectsstay. One row per pull request,min_subjects: 0on the pull request, one internal approver is enough.What does not fit
DEV-0501
peer_approvedisevery pull_requestswithsome approversinside, and names the pull request asprso the inner check can compareusernametoauthor. Two quantifiers in one sentence is outside the grammar. It fits by making the pull request a subject, as DEV-0401 already does:That needs
fromto walk into the deployment's pull requests with the deployment's filters applied, which is #52, not this issue.Evaluation by Claude
Better.
wherefilters the item and the assertion is about the leaf named before it.internal_staffpasses, none fails, a missing or non-listapproversfails closed.some approvers: one of: internal(...).some-with-conditions checks in the set fit this way, including DEV-0502 and DEV-0503 withandinwhere. DEV-0501peer_approvedis the one that needs the skeleton, and it is a subject move with the meaning preserved, not a grammar gap.andinwhereis the first place the grammar grows past one phrase per slot. It stays flat, so Rego can split it, but the spec should bound it:andjoins conditions insidewhereonly, never betweenwhereand the assertion and never at the top of a sentence.ToreMerkely commentedon Oct 9, 2026
everyover a list: DEV-0702DEV-0702: emergency changes get a post-implementation review with lessons learned, actions for incidents, and business owner approval.
The check
incidents_have_actionsis "every incident in the review has an action ticket". Todayallover an empty list fails, so the policy needs anany_ofwith anemptyescape. Withevery, the whole check is one line, if the grammar letseveryhold over an empty list.Today
With sentences
What changes
everywalks the list in the path.every review.incidents.action_ticketreadsaction_ticketfrom each incident. Thecheck:block and its ownpathgo away. The same word walks nested lists, so DEV-0501'sallinsideallbecomesevery pull_requests.commits.author._elementsrequirescount(coll) > 0, soallover[]fails and theany_ofwithno_incidentsis needed. Ifeveryover[]holds, as in Rego and in logic, the check is one line. Ifeverykeeps today's rule, theany_ofstays and the check is two lines plus the group.review.incidentsabsent,nullor not a list is a different case from[]. It fails closed asabsentornot_a_listtoday and should keep doing so. The spec should sayeveryover[]passes andeveryover anything that is not a list fails.review.incidentslist and the reader finds the one without a ticket (core: show which item failed in the rows of all and any checks #68). The report'sinputswould spell the path asevery review.incidents.action_ticket, the same words as the policy.any_ofis still needed elsewhere.everydistributes overand, so "every component has a name and a version" (DEV-0302) is two lines. It does not distribute overor, so a real either-or stays inany_of.Evaluation by Claude
Better, on the condition that
everyover an empty list holds.every review.incidents.action_ticket is not emptysays what the control says. Today'sany_ofwithno_incidentsandall_actionedis a workaround for the engine, and a control owner has to work out that the two options together mean "every incident has a ticket".review.incidents, both pass when there are no incidents, both fail on one incident without a ticket.any_ofrow whose expression readsone of: no_incidents(...) | all_actioned(...).everykeeps today's rule that an empty list fails, the new form is wrong, theany_ofcomes back, and the check is no shorter than today. Nine other checks in our set useeveryand would hit the same question whenever a list can be empty.jbpros commentedon Oct 9, 2026
@ToreMerkely
On DEV-0801,
somewith two conditions.You picked the right test case, but the premise is off by one: a sentence holds two conditions on an item, not one. The
whereclause and the assertion are both conditions, and both are leaves, so DEV-0801 fits the grammar as written, in the same shape as thesum of stages.usd where kind equals "model"example:somewalks the approvers,wherekeeps the ones whosestateis APPROVED, and the assertion is about each one'susername. That means what today'sanywith its two-check option means: at least one approver who is APPROVED and is internal staff. One row per pull request as today, andmin_subjects: 0stays on the pull request.Your rewrite works, but as you say it changes the meaning, from "one internal approver is enough" to "every APPROVED approver must be internal", and changes the rows, and needs per-parent
min_subjectsfrom #113. None of that is forced by the grammar. The derived subject with a parent is worth its own thread on #113 or #52, but it shouldn't be the way to write this policy.Where you have a real point is three or more conditions on the same item. Today an
any_ofoption holds any number of checks that must all pass, and onewhereplus one assertion holds two. So the issue now letswheretake several conditions joined byand:That's a flat list of leaves, not a tree: no brackets, no precedence, and Rego splits it the way it matches everything else. "Or" stays in the skeleton as
any_ofoptions, because each option has a name and the report says which one passed.Two things changed in the issue since you wrote this. It never said what
whereapplies to, which is why it read as one condition per sentence, so the Selectors section now does. Andparams.xbecame$params.x: a bare word is always a field of the data, and one$marks what the policy introduced,$params,$inputand a name given byas, so a subject with a field calledparamsorprcan't be mistaken for them.jbpros commentedon Oct 9, 2026
@ToreMerkely
On DEV-0702,
everyover a list.You're right that the whole check turns on what
everydoes over an empty list, and that it's not a syntax question. It's the decision ergo already made forall, and the reference states it as a principle: an empty list fails with causevalue, because no commits isn't proof that every commit is signed.I'd keep that rule. Fail-closed rests on nothing passing because there was nothing to check, and "every incident has a ticket" passing on a review that recorded no incidents is the quiet pass that principle exists to stop. Rego and logic say a universal over the empty set holds, but ergo isn't a logic, and
$min_subjectsalready breaks with logic for the same reason. Nine checks hitting this is an argument for making "empty is fine" easy to say, not for making it the default.English already has the phrase: "every incident, if any, has an action ticket".
if anyis a modifier after the path. It says in the policy that no incidents is fine, the report prints it, and an auditor reading the row sees the author chose that. Without it,everyover[]fails asvalue, asalldoes today. A missing or non-listreview.incidentsfails asabsentorunusableeither way, which is your third point, and the spec line should say both. Over nested lists,if anycovers every list the sentence walks, since one modifier can't point at a level. Withsome,if anyis written wrong, because "some approver, if any" would let an empty list pass a check that asks for at least one.It sits after the path rather than at the end,
, or there are none, for a reason that's about the grammar: a value can contain commas,is one of "a", "b", and a path can't, soevery <path>, if any, <assertion>is one unambiguous pattern.So the one-line form doesn't depend on changing the semantics, it depends on a modifier, and the
any_ofwithemptygoes away for this case. Your other points hold as written:everywalks nested lists, "every component has a name and a version" is two lines because it's two assertions, and "or" stays inany_of.The issue now has
if anyunder Selectors, with the empty-list rule next to it.spec: follow #174's for line and names without $ in the sentence draft
12 remaining items
spec: allow letters, digits, _ and - in a bare key, as #174 does
spec: say contains with a list of values alone is written wrong
spec: state that every bare name in a one-line every or some is the i…
spec: fold applies_to into from as a where
spec: chain subjects by name where a from repeats a lookup
spec: rewrite the sentence findings for the updated issue
spec: read is one of and is in as one phrase
spec: read assertions joined after a one-line every or some on the item
spec: write the six pr-reviewer filters as where count of again
spec: say why DEV-0407 keeps its for line
spec: count count of and sum of as list walks toward the limit
spec: show a count of walking a list toward the depth limit
spec: drop the one-line subjects form
spec: write a position as item 3 of items, first of items or last of …
spec: add the sentence draft, its corpus and its grammar in ABNF (#180)