Repository navigation
core: let from end with a step that names each subject - #66
Merged
Merged
Conversation
A from can end with {"each": "name"}. Over an object, every entry becomes a
subject identified by its key, and "keys" says which entries must be there,
so a missing one fails as absent. A path that starts with $name reads the
subject, so a check on a list item can compare it with its subject.
Changes the report: a path whose first key starts with a single $ is now a
name, so a key like $schema has to be written as {"literal": "$schema"}.
Inside all and any, a path read through a name or $$input is now shown in the
row and counted for the cause, so a missing one fails as absent, not value.
Part of #52. Replaces #42.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
each already means a path into nested lists on all and any, so the step that names each subject is called each_as instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A with on data nested under report's own with copies the whole document, which made every policy about 20 times slower. Names now travel in input, which report sets once, so requirements without a step pay nothing and ones with a step grow in line with their subjects. Also count a name or $$input read by an element check's own path towards its cause, let an id start with a name, name a badly written from as invalid in $min_subjects, and reword the $well_formed description. Changes the report: a missing $$input value in the path of a check inside all or any now fails as absent, not value. A literal or selector in from now fails $well_formed, where it used to find nothing and could pass with min_subjects: 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jbpros
marked this pull request as ready for review
October 2, 2026 07:16
jbpros
marked this pull request as draft
October 2, 2026 07:19
jbpros
marked this pull request as ready for review
October 2, 2026 07:25
jbpros
added a commit
that referenced
this pull request
Oct 2, 2026
A misspelt name in an all or any's each path read nothing, so the list had no items and the check failed with cause value. As a filter, that ruled every subject out. The read now counts, so it fails as absent. Changes the report: an all or any whose each path starts with a name that can't be read now fails as absent, not value. Main has this since names were added in #66. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jbpros
added a commit
that referenced
this pull request
Oct 2, 2026
…e other (#69) Second half of #52. An `all` or `any` can now hold another `all` or `any`, an `any_of` option can hold an `all` or `any`, and `as` names the outer item so the inner check can still read it. This is what SDLC-CTRL-0007 needs to drop its glue. Its peer approval needs three things of the same approver, so they sit in one `any_of` option: ```rego "from": ["pull_requests", {"each_as": "pr"}], ... {"op": "any", "path": ["approvers"], "as": "approver", "check": {"op": "any_of", "options": {"peer": [ {"op": "equals", "path": ["state"], "value": "APPROVED"}, {"op": "compare", "left": ["username"], "right": ["$pr", "author"], "cmp": "ne"}, {"op": "all", "path": ["$pr", "commits"], "check": {"op": "compare_time", "left": ["$approver", "timestamp"], "right": ["timestamp"], "cmp": "gt"}}, ]}}} ``` It renders as `some approvers as $approver: one of: peer(state == APPROVED and username ne $pr.author and every $pr.commits: $approver.timestamp gt timestamp)`. I rewrote 0007 in a scratch copy of sdlc-policies with all its glue removed (148 lines to 120): 23 of its 25 tests pass against this branch. The other two expect cause `value` for a pull request with no author. It now fails as `absent`, with `$pr.author: null` in the row, which is more accurate. A commit with no timestamp fails the check, which is the bug in 0007's glue described in #52. ## Rules - `as` takes the same names as `each_as`. With `each`, it names the inner item. - A name given twice along a chain (by `from`, an outer `as`, or an inner `as`), or a badly written one, fails the check with cause `absent` and shows as `<name given twice>` or `<invalid name>`. It isn't `value`, because in `applies_to` that would rule every subject out and let the requirement pass with `min_subjects: 0`. Separate checks can reuse a name. - One level of nesting. An `any_of` doesn't count as a level, but an `all`/`any` in its options does. A third `all`/`any` fails as `absent` and shows `<nested too deep>`. It can't be allowed by accident: letting the innermost check be another list check makes Rego reject the policy as recursive. - Inner lists must be non-empty, like outer ones. ## Rows Option A from the discussion: the row shows the lists and the values read through outer names (`approvers[]`, `$pr.commits`), but not which approver failed. Names given by `as` belong to one item, so reads inside the list check that gives them aren't shown in the row and don't decide the cause. A read of the same name anywhere else counts, and fails as `absent` because nothing gives it there. A path starting with one is shown as a path inside the item (`approvers[].timestamp`). Showing which item failed is #68. ## Changes the report - An `all`/`any` whose `each` path starts with a name that can't be read now fails as `absent`, not `value`. That hole has been on main since #66. - An `all`/`any` in an `any_of` option used to fail with cause `value` whatever the input, and render as a blank. It's now evaluated and rendered. - An `all`/`any` whose inner check reads a path starting with `$$input` or a name now shows the list beside that value. Before, only the value was shown. The test from #64 that pins "shown once" still holds, with the list added. ## Checked - `opa check --strict`, `opa fmt`, `opa test`: 499/499, with every line covered. - Differential fuzz against main: 3,000 generated policies give byte-identical reports and violations. - Property fuzz, 2,000 cases with data shaped so a good share of rows pass (about 1,400 passing rows in total): - `as: "it"` with `$it` paths gives the same verdicts and causes as no `as`. - `all xs: all k: check` gives the same verdicts as `all xs` with `each: ["k"]`. - Nested `all`/`any` with `as` names in their paths give the same verdicts as without. - Property fuzz, 1,600 more cases: a list check wrapped alone in an `any_of` option gives the same verdicts as the list check, at the top and inside an outer list, and adding a condition to that option never makes it pass more. - Mutations: dropping any of the 25 guards (8 for nesting and names, 7 for the causes and expressions of badly written list checks, 10 for list checks in `any_of`) makes a test fail, and so does dropping any of the 3 that keep a name to its own list. One mutation hit a guard that turned out to be redundant, so it and two copies of it were removed. - Oracle fuzz, 3,600 rows: random nested checks with names given, misspelt, clashing or out of scope. A Python oracle works out from the structure which ones are broken or read a name nothing gives. Broken ones always fail as `absent`, unbound reads never fail as `value`, and as a filter none lets the requirement pass. Run against the commit before the scoping fix, it finds that regression, so it can see this kind of bug. - Speed: 1,000 PRs × 3 approvers × 5 commits take 3.4s, and time grows in line with input size (500: 1.8s, 2,000: 6.6s). - Wasm: nested checks with names give the same rows as `opa eval`, apart from the check order already in #67. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First half of #52. Replaces #42.
fromcan end with{"each_as": "name"}. It isn't calledeach, becauseeachalready means a path into nested lists onallandany:"keys": [...]says which entries must be there. A missing one is still a subject and fails asabsent. Entries that aren't listed aren't checked.$namereads the subject. This matters insideallandany, where a check on an item can now compare it with its subject ("right": ["$pr", "author"]). The row shows the value read through the name beside the list.A badly written step (not last, more than one, a name that isn't a string or starts with
$,keysthat isn't a list, or an extra field) fails$well_formedand gives no subjects.Not in this PR, coming in the second half:
asonall/anyto name the item, and refs that start with a name. For now{"ref": ["$pr", ...]}fails closed as<invalid ref>.keyscan't be a ref yet either.Changes the report
$is now a name. A key like$schemamust be written as{"literal": "$schema"}. Without that, the read fails asabsent.all/any, a path read through a name or$$inputis now in the row'sinputsand counted for the cause. A missing$$inputvalue inside an element check used to give causevalue, and now givesabsent.$$inputvalue in the path of a check insideall/any("path": ["$$input", ...]) now fails asabsent, notvalue.literalor selector infromnow fails$well_formed.fromnever read them, so such a requirement found nothing and passed withmin_subjects: 0.$well_formeddescription, expression and inputs only change for requirements whosefromhas a step.value_atand$$input. While ergo checks,inputholds ergo's names, not the policy's input.How names reach the checks
Names travel in
input, set once byreportand per subject for requirements with a step. Awithondatanested insidereport's ownwith data.ergo_documentcopies the whole document each time, which made every policy about 20 times slower. Withinput, 1,000 subjects take 0.35s without a step (main: 0.27s) and 0.94s with one, and the time grows in line with the number of subjects (4,000: 1.3s and 3.7s). The evidence can't give names, becausereportreplacesinputfor the whole evaluation.Checked
opa check --strict,opa fmt,opa test: 458/458, with every line covered. All 416 tests from main pass unchanged.$xkeys wrapped inliteral, give byte-identical reports and violations. Without the wrapping, 34 of 500 differ, so the fuzz does catch the intended change.from: [items, {each_as: "it"}]with every path prefixed by$itgives the same verdicts and causes as plainfrom: [items].$and last-step rules, extra fields, refs to names, missing key asnull, name reads in causes, from in$well_formed) makes at least one test fail.🤖 Generated with Claude Code