Skip to content

core: let a requirement take its subjects from an object's keys - #42

Closed
pbeckham wants to merge 1 commit into
mainfrom
core/subjects-by-key
Closed

pbeckham wants to merge 1 commit into
mainfrom
core/subjects-by-key

Conversation

@pbeckham

@pbeckham pbeckham commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

Adds a keys field to requirements. Each listed key of the object at from becomes a subject, with the key as its id. A key the object doesn't have is still a subject, so its checks fail as absent under 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. from turns 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:

  • Reshape the input into a list before calling ergo. The checks are written once, but the policy is no longer checking the evidence as it arrived. Whether a missing run is caught depends on Rego outside ergo, which ergo's tests can't protect. If that code drops missing names, the requirement passes on the ones that are there.
  • Write one requirement per name. Nothing is reshaped, but the same checks are copied for each name, and the report's requirement names change with the parameters.
  • Make the parent object the subject and generate checks per name (unit-test.passed, system-test.passed, ...). There's one requirement, but the checks multiply with the names, and the report says the build failed system-test.passed rather than that the system test run failed passed.

With keys, the checks are written once against the evidence as it arrived, and each required name is checked by name:

"tests_passed": {
	"subject_type": "test run",
	"from": ["build", "test_runs"],
	"keys": ["unit-test", "integration-test", "system-test"],
	"checks": {
		"passed": {"description": "The tests passed", "op": "equals", "path": ["result"], "value": "passed"},
		"report": {"description": "The test report is attached", "op": "present", "path": ["report_url"]},
	},
}

Against the input above, the system test run fails passed and report with cause absent, and the smoke test run plays no part. A count-based check like min_subjects couldn't tell those apart, because there are still three entries.

Failing closed

  • Keys are sorted and duplicates dropped, so key order doesn't change the report.
  • from leading nowhere, or to something that isn't an object: every key is still a subject and fails as absent.
  • An empty keys list gives no subjects, so $min_subjects fails.
  • keys that isn't a list, or keys together with id, fails $well_formed, and the requirement is never met, even with min_subjects: 0 or require: "some". requirement_satisfied now checks this itself, because it doesn't go through well_formed.

Requirements without keys produce the same report as before. The $well_formed row only gains its keys and id inputs when keys is set.

Checks

  • opa check --strict . --ignore .github, opa fmt --list . (prints nothing) and opa test . --ignore .github: 332/332 pass, 100% coverage.
  • Removing the new guard from requirement_satisfied fails four of the new tests. Reading the id from the subject instead of the key fails seven.
  • The REFERENCE.md example output comes from running the example with opa eval.

Not in this PR

A null item in a list-valued from is counted as a subject but loses its check rows, because its id can't be read from null. The requirement still fails, but the rows that would explain why are missing. That bug already exists on main and gets its own fix. Keyed subjects don't run into it, because their id comes from the key.

🤖 Generated with Claude Code

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>
@jbpros

jbpros commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

With require: "some", a requirement with keys passes as soon as one key passes, even if the other keys are missing. With keys a and b and only a in the object, the report is compliant: true, and b fails passed as absent.

That's how some works everywhere else, so I wouldn't forbid it, because "at least one of these suites ran" is a fair thing to ask for. But the PR presents keys as "these names must be there", so someone could easily reach for some and get a pass they didn't expect. Could REFERENCE.md say what keys with some means, and could a test pin that behaviour down?

Separately, it would help if keys could also mean "every key the object has", for when the names aren't known in advance. Today a policy can do it with PR 42 by building the list itself:

run_keys := sort(object.keys(input.runs)) if is_object(input.runs) else := []

That works, but both parts are easy to get wrong. Without sort, keys is a set and $well_formed fails. Without the is_object guard, a missing runs or one that isn't an object makes the whole report undefined, so allow is false and there's no report saying why. Something like "keys": "*" would remove that code, and $min_subjects would still catch an empty or missing object.

@jbpros

jbpros commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

I've opened #52 for a broader way of choosing subjects that would cover keys too, as one step in from. It's worth deciding there before this merges, because once keys is a field on the requirement, ergo has to keep supporting it.

@jbpros jbpros added impacts-ergo-policies Impacts ergo policy schema. Careful design attention needed. core Relates to the ergo library or format themselves. labels Oct 1, 2026
@jbpros

jbpros commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

#66 will likely replace this. It's the first half of #52, and it moves keys into a step at the end of from:

"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 absent. Keys are sorted and duplicates dropped. Unlisted entries aren't checked. Without keys, every entry of the object is a subject. It also names the subject ($run), so checks inside all/any can read it.

Leaving this open until #66 is settled.

jbpros added a commit that referenced this pull request Oct 2, 2026
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>
@jbpros

jbpros commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Replaced by #66, now merged: keys lives on the each_as step at the end of from.

@jbpros jbpros closed this Oct 2, 2026
@jbpros
jbpros deleted the core/subjects-by-key branch October 9, 2026 06:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Relates to the ergo library or format themselves. impacts-ergo-policies Impacts ergo policy schema. Careful design attention needed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants