Skip to content

core: write every selector as a string and every check as a sentence #174

Open

Description

@jbpros

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 empty

A check without a description is just the sentence:

checks:
  approved: approved_by is not empty

The 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 true

And 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.01

Proposed:

costs_add_up: sum of stages.usd where kind is "model" equals total_usd within 0.01

The 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 eval function 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.
  • from takes the same shape as a for item, so applies_to goes, 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 items and items[$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 of says on the line that a list is expected.
  • A key from a for name is what pr-reviewer's keys_match needs. Today a ref can't start with a name, so it's a custom operator. With for, 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, count and one, are never a field name on the right side. is empty is the phrase, is true is 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_usd means is total_usd, and the report prints it only where it's needed.
  • The reference after named by is $params or $input plus one key, so the path can go on after it.
  • plus can't follow a where, because count of x where n is m plus k is t reads three ways. Put the where on 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 where or every per sentence
  • and does two jobs and nothing else: it joins conditions inside a where, and it joins whole assertions, see Two quantifiers. Each side of an and is a complete, simple condition or assertion, so it's a flat list, not a tree
  • a where condition can start with count of or sum of, like where 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.author

Read aloud: for every pull request, and some approver of it with state APPROVED, the approver isn't the author.

  • Each item in for is every or some, a path, an optional where with and, and as a name. Items nest left to right, so pr.approvers can use pr. Two is the limit, the one level of nesting Rego gives today, and a third is written wrong.

  • In assert and in a later item of for, a bare name is the subject's field and a named item is reached by its name. So started_at in for: some approvals where role is "qa" as approval with assert: approval.approved_at is before started_at is the subject's. In a one-line every or some, every bare name is the item's, in where and on the right side alike, so some approvers.timestamp where state is "APPROVED" is after last_commit_timestamp reads both from each approver. The sentence ends at and. An assertion joined with and starts from the subject again, exactly as a new line under assert does, so in every items.price is at least 2 and owner is "x", owner is the subject's. To compare an item with the subject, use a for line. That's two scoping rules, and the spec has to state both.

  • Fail-closed is unchanged. some over an empty list fails, every over one fails unless it says if any, a missing or non-list path fails as absent or unusable, and an item a where can't decide fails the try with its cause.

  • assert can be a list, or one line with and between assertions. Both mean the same: each assertion starts from the subject, or from the for names, whether it's on its own line or after an and. 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 expression joins the lines with and. A failed row can then say which line failed.

  • The one-line form stays for the common case. every commits.signed is true is the same check as for: every commits as c with assert: 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 a for line 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 $applies row stays. Its expression is the conditions as written, environment is "prod" and change_type is "emergency", and its inputs show what was read, change_type = "bug_fix", so a reader sees which condition failed. The rule every where has applies: a deployment with no change_type fails the requirement as absent rather 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 request is reached through a deployment, so its rows carry both ids, {"type": "pull request", "id": 8, "in": {"deployment": "d-1"}}, min_subjects applies per parent, and a check on a pull request reads its deployment as deployment.started_at, by the subject's name, with no as needed. 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 defect subject that is a defect with 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 no in.

  • 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 for items, has a fixed limit. The corpus says what it is: the deepest real check walks three lists, two chained subjects then two for items with one list each, and that's also today's deepest check. So the spec names three list walks, counted as walks, not subjects. A count of or sum of is a walk too, in a where as well, because it reads every item of its list just as every does. So for: every pull_requests as pr with assert: count of pr.reviews is at least 2 walks two lists.

  • A subject's own name in its own from is the input field, never the subject, so sdlc-policies 0004 can keep a subject called artifact read from input.artifact. Any other subject's name starts a chain, and $input.artifact reaches the field when a subject of that name exists. A subject with no from at all reads the input field of its name, and id without from works 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, lockfile was considered for the case where every subject is a plain field, and dropped: one subject in the whole corpus fits, and a by id suffix 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 subjects section stays. The far end of this is marking the row level with an id on a for item 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:

  • expression on each check and in violations is the canonical sentence, and today's renderings go away.
  • A row's inputs name paths as strings already, today as stages[].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, so every commits.signed is true records [true, false], and failed_items keeps the whole items for context. Today an all row records the whole list.
  • A check with a for line renders its expression as the two lines joined with a colon, in canonical form, the shape today's nested rendering already has. Its inputs record the list the first quantifier walked, whole, and failed_items the items of that quantifier that failed, as today for a nested check.
  • Names from as appear in the report under as, 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 $pr was. as is left out where the policy gave no name. It isn't called name because inputs already uses that for the path.
  • failed_items.path keeps brackets, pull_requests[1], zero-based, even though the policy writes item 2 of pull_requests. It's a locator for tools, like JSONPath, and it sits next to the item's value, 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 expression on 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, trim and json.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 empty in 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_formed and its rows fail as ill_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 word every, and [2] becomes item 3 of, with items[2] still accepted.
  • The value is written as JSON writes a scalar: quoted text, a number, true, false or null. 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 separate not slot. That reads better and parses as one pattern with an optional word.
  • is is the verb, not must, see The proposal. The key is assert, 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 #, so message 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 empty or from: "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

Plan

  1. Done: the spec/sentences branch 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.
  2. 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 by or brackets.
  3. Bring the branch in line with this issue, write the grammar into the spec, then build it in Rego behind the same $well_formed and ill_formed mechanics 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

  1. added
    questionFurther information is requested
    coreRelates to the ergo library or format themselves.
    impacts-ergo-policiesImpacts ergo policy schema. Careful design attention needed.
    proposalA design written down, waiting for comments.
    and removed
    questionFurther information is requested
    on Oct 9, 2026
  2. changed the title [-]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[/+] on Oct 9, 2026
  3. added
    impacts-ergo-reportsImpacts ergo report schema. Careful design attention needed.
    on Oct 9, 2026
  4. ToreMerkely commented on Oct 9, 2026

    @ToreMerkely
    Contributor

    some with two conditions: DEV-0801

    DEV-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: where keeps 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

    subjects:
      pull request:
        from: [pull_requests]
        id: [number]
        applies_to:
          external_author: { op: in, path: [author], values: { ref: [$$params, external_contributors] } }
    requirements:
      outsourced_work_reviewed:
        subject: pull request
        min_subjects: 0
        checks:
          internal_review:
            description: An internal employee approved the external contributor's pull request
            op: any
            path: [approvers]
            check:
              op: any_of
              options:
                internal:
                  - { op: equals, path: [state], value: APPROVED }
                  - { op: in, path: [username], values: { ref: [$$params, internal_staff] } }

    With sentences

    subjects:
      pull request:
        from: pull_requests
        id: number
        applies_to:
          external_author: author is in $params.external_contributors
    requirements:
      outsourced_work_reviewed:
        subject: pull request
        min_subjects: 0
        checks:
          internal_review:
            description: An internal employee approved the external contributor's pull request
            assert: some approvers.username where state is "APPROVED" is in $params.internal_staff

    What changes

    • The check is one line. some walks approvers, where keeps those whose state is APPROVED, and the assertion is about each one's username. At least one has to pass, as any does today.
    • The any_of with one option goes away. Today a single-option any_of is how two conditions are joined with and. The sentence puts one in where and one in the assertion.
    • The leaf comes before the filter. The path approvers.username names what the assertion is about. where state is "APPROVED" applies to the approver, not to the username. That is the reading to learn.
    • Three or more conditions. where takes and as 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. or stays in any_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, $input and a name from as carry a $, so a field called params cannot be mistaken for them.
    • Meaning, rows and min_subjects stay. One row per pull request, min_subjects: 0 on the pull request, one internal approver is enough.

    What does not fit

    DEV-0501 peer_approved is every pull_requests with some approvers inside, and names the pull request as pr so the inner check can compare username to author. Two quantifiers in one sentence is outside the grammar. It fits by making the pull request a subject, as DEV-0401 already does:

    subjects:
      pull request:
        from: $deploy.pull_requests as pr
        id: number
    requirements:
      peer_approved:
        subject: pull request
        checks:
          not_the_author: some approvers.username where state is "APPROVED" is not $pr.author

    That needs from to walk into the deployment's pull requests with the deployment's filters applied, which is #52, not this issue.

    Evaluation by Claude

    Better.

    • Readability: better. One sentence says what the control says. Today's form says "any of" with one option and means "all of", which a control owner has to be told. The one thing to learn is that where filters the item and the assertion is about the leaf named before it.
    • Correctness: the same. At least one APPROVED approver in internal_staff passes, none fails, a missing or non-list approvers fails closed.
    • Report: the same rows. The expression becomes the sentence, which is what the policy says, instead of some approvers: one of: internal(...).
    • Scope: nine of the ten some-with-conditions checks in the set fit this way, including DEV-0502 and DEV-0503 with and in where. DEV-0501 peer_approved is the one that needs the skeleton, and it is a subject move with the meaning preserved, not a grammar gap.
    • Risk: and in where is 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: and joins conditions inside where only, never between where and the assertion and never at the top of a sentence.
  5. ToreMerkely commented on Oct 9, 2026

    @ToreMerkely
    Contributor

    every over a list: DEV-0702

    DEV-0702: emergency changes get a post-implementation review with lessons learned, actions for incidents, and business owner approval.

    The check incidents_have_actions is "every incident in the review has an action ticket". Today all over an empty list fails, so the policy needs an any_of with an empty escape. With every, the whole check is one line, if the grammar lets every hold over an empty list.

    Today

    subjects:
      emergency change:
        from: [deployments]
        id: [id]
        applies_to:
          production:
            op: equals
            path: [environment]
            value: prod
          emergency:
            op: equals
            path: [change_type]
            value: emergency
    requirements:
      emergency_change_reviewed:
        subject: emergency change
        min_subjects: 0
        checks:
          lessons_learned:
            description: The review records lessons learned
            op: non_empty_string
            path: [review, lessons_learned]
          incidents_have_actions:
            description: Every implementation incident has an action to resolve it
            op: any_of
            options:
              no_incidents: [{ op: empty, path: [review, incidents] }]
              all_actioned:
                - op: all
                  path: [review, incidents]
                  check: { op: non_empty_string, path: [action_ticket] }
          business_owner_approved:
            description: The business owner approved the review
            op: equals
            path: [review, approved_by_role]
            value: business_owner
          reviewed_after_change:
            description: The review came after the change was deployed
            op: compare_time
            left: [review, approved_at]
            right: [started_at]
            cmp: gt

    With sentences

    subjects:
      emergency change:
        from: deployments
        id: id
        applies_to:
          production: environment is "prod"
          emergency: change_type is "emergency"
    requirements:
      emergency_change_reviewed:
        subject: emergency change
        min_subjects: 0
        checks:
          lessons_learned:
            description: The review records lessons learned
            assert: review.lessons_learned is not empty
          incidents_have_actions:
            description: Every implementation incident has an action to resolve it
            assert: every review.incidents.action_ticket is not empty
          business_owner_approved:
            description: The business owner approved the review
            assert: review.approved_by_role is "business_owner"
          reviewed_after_change:
            description: The review came after the change was deployed
            assert: review.approved_at is after started_at

    What changes

    • every walks the list in the path. every review.incidents.action_ticket reads action_ticket from each incident. The check: block and its own path go away. The same word walks nested lists, so DEV-0501's all inside all becomes every pull_requests.commits.author.
    • The empty list decides whether this is one line. Today _elements requires count(coll) > 0, so all over [] fails and the any_of with no_incidents is needed. If every over [] holds, as in Rego and in logic, the check is one line. If every keeps today's rule, the any_of stays and the check is two lines plus the group.
    • A missing list must still fail. review.incidents absent, null or not a list is a different case from []. It fails closed as absent or not_a_list today and should keep doing so. The spec should say every over [] passes and every over anything that is not a list fails.
    • A violation names the item. Today the violation shows the whole review.incidents list 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's inputs would spell the path as every review.incidents.action_ticket, the same words as the policy.
    • any_of is still needed elsewhere. every distributes over and, so "every component has a name and a version" (DEV-0302) is two lines. It does not distribute over or, so a real either-or stays in any_of.

    Evaluation by Claude

    Better, on the condition that every over an empty list holds.

    • Readability: every review.incidents.action_ticket is not empty says what the control says. Today's any_of with no_incidents and all_actioned is a workaround for the engine, and a control owner has to work out that the two options together mean "every incident has a ticket".
    • Correctness: the same. Both fail on a missing or non-list review.incidents, both pass when there are no incidents, both fail on one incident without a ticket.
    • Report: better. One row with one expression, instead of an any_of row whose expression reads one of: no_incidents(...) | all_actioned(...).
    • Risk: the one-line form depends on a semantic decision the issue has not made. If every keeps today's rule that an empty list fails, the new form is wrong, the any_of comes back, and the check is no shorter than today. Nine other checks in our set use every and would hit the same question whenever a list can be empty.
  6. jbpros commented on Oct 9, 2026

    @jbpros
    ContributorAuthor

    @ToreMerkely

    On DEV-0801, some with 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 where clause and the assertion are both conditions, and both are leaves, so DEV-0801 fits the grammar as written, in the same shape as the sum of stages.usd where kind equals "model" example:

    subjects:
      pull request:
        from: pull_requests
        id: number
        applies_to:
          external_author: author is in $params.external_contributors
    requirements:
      outsourced_work_reviewed:
        subject: pull request
        min_subjects: 0
        checks:
          internal_review:
            description: An internal employee approved the external contributor's pull request
            assert: some approvers.username where state is "APPROVED" is in $params.internal_staff

    some walks the approvers, where keeps the ones whose state is APPROVED, and the assertion is about each one's username. That means what today's any with its two-check option means: at least one approver who is APPROVED and is internal staff. One row per pull request as today, and min_subjects: 0 stays 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_subjects from #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_of option holds any number of checks that must all pass, and one where plus one assertion holds two. So the issue now lets where take several conditions joined by and:

    assert: some approvers.username where state is "APPROVED" and submitted_at is after created_at is in $params.internal_staff

    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_of options, 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 where applies to, which is why it read as one condition per sentence, so the Selectors section now does. And params.x became $params.x: a bare word is always a field of the data, and one $ marks what the policy introduced, $params, $input and a name given by as, so a subject with a field called params or pr can't be mistaken for them.

  7. jbpros commented on Oct 9, 2026

    @jbpros
    ContributorAuthor

    @ToreMerkely

    On DEV-0702, every over a list.

    You're right that the whole check turns on what every does over an empty list, and that it's not a syntax question. It's the decision ergo already made for all, and the reference states it as a principle: an empty list fails with cause value, 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_subjects already 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".

    incidents_have_actions:
      description: Every implementation incident has an action to resolve it
      assert: every review.incidents.action_ticket, if any, is not empty

    if any is 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, every over [] fails as value, as all does today. A missing or non-list review.incidents fails as absent or unusable either way, which is your third point, and the spec line should say both. Over nested lists, if any covers every list the sentence walks, since one modifier can't point at a level. With some, if any is 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, so every <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_of with empty goes away for this case. Your other points hold as written: every walks nested lists, "every component has a name and a version" is two lines because it's two assertions, and "or" stays in any_of.

    The issue now has if any under Selectors, with the empty-list rule next to it.

  8. added a commit that references this issue on Oct 9, 2026
  9. 12 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    coreRelates to the ergo library or format themselves.impacts-ergo-policiesImpacts ergo policy schema. Careful design attention needed.impacts-ergo-reportsImpacts ergo report schema. Careful design attention needed.proposalA design written down, waiting for comments.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions