Policy engines that serve security teams tend to fall into one of two failure modes. The first is too simple: a config file with a list of blocked package names and a severity threshold, which covers about 20% of real-world governance requirements before teams start hacking workarounds. The second is too expressive: full Rego, OPA datalog, or some bespoke DSL that your average platform engineer won't touch without a two-day training session.
When we started building Repohelm's policy layer, we had a constraint we kept returning to: the rules need to be written by the same engineers who write .github/workflows/*.yml files. Not by security architects. Not by people with OPA certifications. The everyday platform engineer who also owns Terraform modules and on-call rotations.
This post is about how we ended up with a dual-evaluation model, what we got wrong in v1, and the specific tradeoffs we chose in the design of v2.
The v1 design: YAML only, and why it wasn't enough
Version one of the policy engine was straightforward YAML with a handful of top-level keys: severity_threshold, blocked_packages, allowed_licenses, auto_merge. Teams could drop a .repohelm.yaml at their repo root or org root and get meaningful behavior immediately. Adoption was fast. The feedback loop was tight.
But within six weeks of running it against real codebases, we hit the ceiling. The first clear signal was a request we got from a team managing about 40 Go and Python repos: they wanted different severity thresholds per ecosystem, because a CVSS 7.0 in a Go service meant something very different than a CVSS 7.0 in a data science notebook that never reached production. Flat YAML can't express that conditional logic cleanly. You'd need a separate policy file per repo, which defeats the org-level governance model.
The second signal was license risk. Teams wanted to allow MIT and Apache-2.0 everywhere, allow LGPL-2.1 in a handful of specifically named repositories, and block AGPL-3.0 in anything customer-facing. That three-level conditional is completely reasonable from a legal standpoint. It's not expressible in a flat allowlist.
So we had a choice: extend the YAML schema with increasingly baroque conditional syntax, or expose the OPA layer that was already powering our internal evaluation and let teams write Rego directly.
The dual-evaluation model
We landed on a layered approach. The YAML schema handles the common 80%: severity thresholds, ecosystem-specific overrides, license allowlists, auto-merge eligibility rules, and notification routing. For teams whose requirements go beyond that surface area, a policy.rego file can coexist in the same directory and will be evaluated after the YAML pass.
The evaluation order matters. YAML rules run first and produce a set of preliminary decisions: allow, block, or escalate for each finding. The Rego policy, if present, receives those preliminary decisions as input context alongside the raw finding data, and can override or augment them. This means YAML is always the floor, and Rego is the escape hatch for teams that need it.
What this gives us is graceful complexity scaling. A team with simple requirements never sees OPA. A team running a mixed open-source codebase with GPL boundary requirements can write five lines of Rego and get precise enforcement. Neither team pays the cognitive cost of the other's requirements.
What the input document looks like
For each scan finding, the Rego policy receives an input document that includes the package name, ecosystem, version, CVE identifiers (if applicable), CVSS base score, EPSS probability score, SPDX license expression, whether the dependency is direct or transitive, its depth in the dependency graph, and whether our reachability analysis found an active call path to the vulnerable function.
That last two fields, transitive depth and reachability, are where the interesting decisions happen. A transitive dependency at depth 4 with no reachable call path is not the same risk as a direct dependency with a confirmed reachable exploit. Our default YAML rules reflect this: CVSS threshold for transitive-depth-3-plus findings is 9.0 by default, versus 7.0 for direct dependencies. Teams can override this, but the opinionated default saves them from having to think through the tradeoff themselves.
We spent time deciding whether to expose EPSS scores in the policy input at all. EPSS (Exploit Prediction Scoring System) from FIRST.org gives a probability estimate for exploitation in the next 30 days, but it's noisy for lower-severity CVEs and can create false confidence. We expose it as an optional input field, and our built-in YAML schema doesn't use it in default rules. Teams that want to write "skip this finding if CVSS is under 7.0 and EPSS is below 0.05" can do that in Rego. We just don't make it the default.
The mistake we made: caching evaluation results
We're not saying caching policy evaluation results is wrong. What we got wrong was caching at the wrong granularity.
In the early builds, we cached the entire policy decision for a (package, version, policy_hash) tuple. This looked correct: if the policy file hadn't changed and the package version hadn't changed, surely the decision would be the same? The problem was that policy decisions can depend on context that isn't captured in those three keys. The most painful example: auto-merge eligibility depends on whether CI is passing for the specific PR, the branch protection rules for the target repo, and whether there are other open PRs touching the same dependency. None of that is in the cache key.
We burned three weeks of bug reports before realizing that "policy result for (lodash, 4.17.21, policy-hash-abc123) is allow" didn't mean "auto-merge this PR." The policy result was correct in isolation. The context around auto-merge eligibility was not part of what we'd cached. We refactored to separate two distinct evaluation phases: finding-level policy (cacheable) and PR-action policy (not cacheable, always evaluated fresh against live repo state).
Where Dependabot and Renovate don't go
To be direct: Dependabot does not have a policy evaluation layer. It has configuration options for ignored packages, scheduled update windows, and version constraints. Those are useful, but they're closer to update preferences than governance rules. Renovate has more configuration surface area and can be extended significantly, but its configuration model is JSON-based and becomes difficult to reason about beyond a few hundred lines. Neither tool evaluates against a CVE advisory database as part of the PR opening decision. They open PRs for all version updates, which means CVE signal and update noise arrive in the same queue.
Repohelm's policy engine runs after CVE triage and reachability analysis, not before. That ordering matters: you're not writing rules about "what to update" but "what to act on given this specific risk assessment." It's a different mental model for the person writing the policy, and it produces meaningfully different PR volumes in practice.
What we're still figuring out
The part of the policy engine we're least satisfied with is the feedback loop for policy authors. Right now, if you write a Rego rule with a bug, you don't find out until your next scan runs and either produces unexpected decisions or throws an evaluation error. We want a local evaluation sandbox where teams can test a policy file against a sample of their real findings before committing it. That's on the roadmap, but it wasn't ready for the v2 launch.
We're also working through the right approach for policy inheritance in monorepos. Today, a repo-level policy file overrides org-level entirely. Merge semantics, where repo-level rules extend rather than replace org defaults, would be more useful for most teams but introduces complexity in understanding what rule actually fired for a given decision. The audit trail problem is harder than the merge problem.
If you're building something similar, or you have strong opinions about policy-as-code ergonomics from using OPA in other contexts, we're genuinely interested. The design space here is narrower than it looks from the outside, and every sharp edge we've found came from running against real codebases, not from whiteboarding.