Repository navigation
Conversation
A requirement can now list keys. Each key of the object at from becomes a subject, identified by the key, and a key the object doesn't have is still a subject whose checks fail as absent. This lets a policy check a fixed set of named entries, such as a unit, integration and system test run, with its checks written once and without reshaping the input. Requirements without keys report exactly as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
With That's how Separately, it would help if run_keys := sort(object.keys(input.runs)) if is_object(input.runs) else := []That works, but both parts are easy to get wrong. Without |
|
I've opened #52 for a broader way of choosing subjects that would cover |
|
#66 will likely replace this. It's the first half of #52, and it moves "from": ["build", "test_runs", {"each_as": "run", "keys": ["unit-test", "integration-test", "system-test"]}],The behaviour is the same as here. Each key is a subject identified by its key, and a missing key fails as Leaving this open until #66 is settled. |
First half of #52. Replaces #42. `from` can end with `{"each_as": "name"}`. It isn't called `each`, because `each` already means a path into nested lists on `all` and `any`: - Over an object, every entry is a subject, identified by its key and sorted by key. `"keys": [...]` says which entries must be there. A missing one is still a subject and fails as `absent`. Entries that aren't listed aren't checked. - Over a list, every item is a subject, as before. - A path that starts with `$name` reads the subject. This matters inside `all` and `any`, 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. ```rego "from": ["build", "test_runs", {"each_as": "run", "keys": ["unit-test", "integration-test", "system-test"]}], ``` A badly written step (not last, more than one, a name that isn't a string or starts with `$`, `keys` that isn't a list, or an extra field) fails `$well_formed` and gives no subjects. Not in this PR, coming in the second half: `as` on `all`/`any` to name the item, and refs that start with a name. For now `{"ref": ["$pr", ...]}` fails closed as `<invalid ref>`. `keys` can't be a ref yet either. ## Changes the report - A path whose first key starts with a single `$` is now a name. A key like `$schema` must be written as `{"literal": "$schema"}`. Without that, the read fails as `absent`. - Inside `all`/`any`, a path read through a name or `$$input` is now in the row's `inputs` and counted for the cause. A missing `$$input` value inside an element check used to give cause `value`, and now gives `absent`. - A missing `$$input` value in the path of a check inside `all`/`any` (`"path": ["$$input", ...]`) now fails as `absent`, not `value`. - A `literal` or selector in `from` now fails `$well_formed`. `from` never read them, so such a requirement found nothing and passed with `min_subjects: 0`. - The `$well_formed` description, expression and inputs only change for requirements whose `from` has a step. - Custom operators must read the document through `value_at` and `$$input`. While ergo checks, `input` holds ergo's names, not the policy's input. ## How names reach the checks Names travel in `input`, set once by `report` and per subject for requirements with a step. A `with` on `data` nested inside `report`'s own `with data.ergo_document` copies the whole document each time, which made every policy about 20 times slower. With `input`, 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, because `report` replaces `input` for the whole evaluation. ## Checked - `opa check --strict`, `opa fmt`, `opa test`: 458/458, with every line covered. All 416 tests from main pass unchanged. - Differential fuzz against main: 4,000 generated policies without naming steps, with `$x` keys wrapped in `literal`, give byte-identical reports and violations. Without the wrapping, 34 of 500 differ, so the fuzz does catch the intended change. - Property fuzz: 2,000 cases where `from: [items, {each_as: "it"}]` with every path prefixed by `$it` gives the same verdicts and causes as plain `from: [items]`. - Mutations: dropping each new guard (scope in filter causes, the `$` and last-step rules, extra fields, refs to names, missing key as `null`, name reads in causes, from in `$well_formed`) makes at least one test fail. - Not checked: the Wasm runtime. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
Replaced by #66, now merged: |
Adds a
keysfield to requirements. Each listed key of the object atfrombecomes a subject, with the key as its id. A key the object doesn't have is still a subject, so its checks fail asabsentunder that name.Why
Evidence often arrives as an object keyed by name, and the policy knows which names must be there. A build might record its test runs like this:
{"build": {"test_runs": { "unit-test": {"result": "passed", "report_url": "https://ci.example.com/r/101"}, "integration-test": {"result": "passed", "report_url": "https://ci.example.com/r/102"}, "smoke-test": {"result": "passed", "report_url": "https://ci.example.com/r/103"} }}}and the rule is "unit-test, integration-test and system-test each ran, passed and attached a report". Today ergo can't express that directly.
fromturns a list into one subject per item, but an object becomes a single subject, and the entries don't carry their own name. That leaves three workarounds, and each has a cost:unit-test.passed,system-test.passed, ...). There's one requirement, but the checks multiply with the names, and the report says the build failedsystem-test.passedrather than that the system test run failedpassed.With
keys, the checks are written once against the evidence as it arrived, and each required name is checked by name:Against the input above, the system test run fails
passedandreportwith causeabsent, and the smoke test run plays no part. A count-based check likemin_subjectscouldn't tell those apart, because there are still three entries.Failing closed
fromleading nowhere, or to something that isn't an object: every key is still a subject and fails asabsent.keyslist gives no subjects, so$min_subjectsfails.keysthat isn't a list, orkeystogether withid, fails$well_formed, and the requirement is never met, even withmin_subjects: 0orrequire: "some".requirement_satisfiednow checks this itself, because it doesn't go throughwell_formed.Requirements without
keysproduce the same report as before. The$well_formedrow only gains itskeysandidinputs whenkeysis set.Checks
opa check --strict . --ignore .github,opa fmt --list .(prints nothing) andopa test . --ignore .github: 332/332 pass, 100% coverage.requirement_satisfiedfails four of the new tests. Reading the id from the subject instead of the key fails seven.opa eval.Not in this PR
A
nullitem in a list-valuedfromis counted as a subject but loses its check rows, because its id can't be read fromnull. The requirement still fails, but the rows that would explain why are missing. That bug already exists onmainand gets its own fix. Keyed subjects don't run into it, because their id comes from the key.🤖 Generated with Claude Code