Rego is Good. But It Comes at a Cost.
The tradeoffs nobody warned me about before I went all-in on OPA
Rego is Good.
But It Comes at a Cost.
The tradeoffs nobody warned me about before I went all-in on OPA

Ilustration generated with ChatGPT based on concepts from this article
It’s not a surprise that you run into walls with Rego once the policy requirements go beyond simple allow/deny – hierarchical access, group inheritance, role chains, for example, lead to a fight with the language instead of writing policy. The problem lies neither in the tooling nor in the policy writers.
Rego is genuinely strong at what it was built for — it’s JSON-native, production-proven, and backed by a solid ecosystem. Open Policy Agent is even a CNCF graduated project, and that suggests how mature it is. This isn’t about criticizing it.
That said, there’s a reason hierarchical or multi-hop logic quickly becomes painful to model. It’s not just missing features or tooling gaps — it comes down to how the language itself is designed. Once you notice that limitation, you start recognizing it in many places.
What Rego Actually Is, Theoretically
Rego’s docs say it was “inspired by Datalog” but the word ‘inspired’ hides the important difference and as a result, Rego struggles with certain classes of problems, especially hierarchical ones.
Datalog is a formal logic programming language with real mathematical guarantees behind it. Rego borrows the syntax and the evaluation style. It then layers on negation, arithmetic, built-ins, and JSON traversal — but without carrying over the properties that make Datalog what it is.
Think of it this way: Datalog is a proof. Rego is an engineering approximation of that proof. It’s useful and practical, but in certain situations you no longer get the guarantees Datalog is built on.
Negation Without a Safety Net
Probably my favorite guarantee by Datalog is that of the existence of the “unique minimal model” — A single correct answer, determined independently of evaluation order. This guarantee relies on how negation is used.
Datalog does support negation, but only in a restricted, stratified form that preserves this property.
Rego supports negation as well, but without enforcing those constraints — and this is where things start to break down. Consider a simple approval workflow: a team lead can approve a request unless there is a conflict of interest; a conflict exists if the approver hasn’t been cleared; and a user is considered cleared if they are allowed to approve and have been peer-reviewed.
package authz
default can_approve := false
# A Team lead can approve requests, unless there's a conflict of interest
can_approve if {
user.role == "team_lead"
not has_conflict
}
# Conflict exists if the approver hasn't been cleared
has_conflict if {
user.id == request.submitted_by
not is_cleared
}
# A user is cleared if they can approve (peer-reviewed and trusted)
is_cleared if {
can_approve
user.peer_reviewed == true
}
At first glance, this looks reasonable. But there is a dependency cycle through negation:
can_approvedepends onnot has_conflicthas_conflictdepends onnot is_clearedis_cleareddepends oncan_approve
This creates cyclic dependency through a negation. At that point, the policy no longer has a single, clearly defined outcome — not because the engine is non-deterministic, but because the policy specification itself does not determine a single one.
There are multiple logically consistent interpretations of the rules. The Open Policy Agent (OPA) will still return a single result, but that result is determined by its evaluation strategy, not uniquely by the specification itself.
As a concrete demonstration: with default can_approve := false, OPA returns can_approve = false for a peer-reviewed team lead approving their own request. Change only the default to true — same rules, same input — and OPA returns can_approve = true. The rules didn’t change. The outcome did.
The key point is not which outcome is chosen, but that the policy specification itself does not determine a single one — it depends on how it is evaluated.
Why this matters: what looks like a declarative policy stops behaving like one. Instead of describing what should be true, you start depending on how it is evaluated.
This is in contrast to Datalog, where a unique minimal model guarantees that the result is fully determined by the program.
The Recursion Problem
“Does Alice have access to the production system” — imagine a company using role-based access control. Alice has a Backend Engineer role. The role itself is part of the group Payments Team which itself belongs to Finance Tech Department having access to the production system.
Answering wether Alice have access to the production system is not a simple lookup. In reality, roles can belong to groups, groups can belong to other groups and the hierarchies can be many levels deep. It is usually not known in advance how deep the chain goes.
When asking whether Alice can have access is indeed a reachability problem: can I follow a chain of relationships from Alice to something that grants access?
Solving this problem requires recursion. This is where Rego’s critical limitation comes — Rego forbids recursion. Any rule that references itself, even indirectly, is rejected at compile time. Not just unsafe recursion (e.g., non stratified ones in Datalog). All of it.
In Datalog, our example can easily be described as
# Base: Alice has access if she directly has access
has_access("Alice", R) :- direct_access("Alice", R).
# Alice inherits access via groups she belongs to
has_access("Alice", R) :- member("Alice", G), group_access(G, R).
# Membership is recursive (groups inside groups)
member("Alice", G) :- direct_member("Alice", G).
member("Alice", G2) :- member("Alice", G1), group_member(G1, G2).
You can have something similar in
package example
member contains g if {
g := data.direct_member["Alice"][_]
}
member contains g2 if {
g1 := member[_]
g2 := data.group_member[g1][_]
}
has_access contains r if {
g := member[_]
r := data.group_access[g][_]
}
But OPA will reject this at compile time:
rego_recursion_error: rule data.example.member is recursive: data.example.member -> data.example.member
You can verify it on Rego Playground.
The Workaround
A simple workaround could be to simply avoid recursion and instead write, say, a Python script that precompute all the transitive closure of the recursive part — all direct members and indirect members via group membership. Store the result as flat JSON and provide it to OPA as data. Now the above policy simply becomes a straight-forward lookup over the precomputed relationships.
This works but now our logic lives in two places — and neither has the full picture.
The Real Cost
The workaround simply separated the authorization logic across systems — OPA enforces policies whereas another system computes what those policies apply to. The second system might be a batch job, a graph processor, a streaming pipeline. In either of the cases, it is not visible in OPA’s decision logs, it is often outside the policy lifecycle, and it is harder to audit and reason about. In simple terms, the workaround breaks the declarative model — reasoning moves outside Rego, along with its guarantees and visibility.
Why This Matters Theoretically
A quick warning: this section is more theoretical.
Why does OPA have the limitation? Can’t we simply get rid of the restriction?
It isn’t just a tooling limitation. It is a known boundary in logic: first-order logic cannot express graph reachability over arbitrary depth. Having said that, Rego, without recursion, is essentially first-order logic over structured data. That means “for any Rego policy you write, there exists a hierarchy deep enough that it cannot correctly determine reachability.”
Not inefficient. Not inconvenient. Impossible!
Datalog handles this through fix-point evaluation — it keeps applying rules until no new facts emerge. This works because Datalog is restricted: it operates over a finite domain and limits how negation is used, so the computation always converges to a well-defined result. Rego doesn’t impose those constraints. It allows unrestricted negation and operates over unbounded data, where fix-points may not exist or may not converge.
So the issue isn’t just recursion — it’s that fix-point semantics aren’t guaranteed under Rego’s model. Instead, Rego forbids cycles entirely, guaranteeing termination and predictability at the cost of expressiveness.
The Hidden Third Value
Consider the following policy
default allow = false
allow if {
not blocked
}
blocked if {
data.blocklist[input.user]
}
At first glance, this policy looks harmless. Simple, Readable, Declarative.
Now imagine everything is working as expected. Suppose we have a blocklist:
{
"blocklist": {
"alice": true
}
}
and we evaluate:
{ "user": "alice" }
We get blocked = true and allow = falseas alice is in the blocklist. Alice is blocked, that is perfectly fine. But imagine the scenario, where our data lacks the fact that Alice is in the blocklist i.e., our data is
{ }
You now have that blocked = undefined which leads to not blocked = true succeeds and thus allow = true. Hence, Alice is allowed.
Nothing changed in our logic. Only the data disappeared — a realistic failure occurs e.g., the blocklist is not loaded due to a pipeline breakage or change in the file path, or the service times out. The policy has not changed, but the decision flipped from deny to allow.
What Just Happened
In Rego, not blocked doesn’t mean “blocked is false.” It means “blocked cannot be proven true.” This is where things get subtle. Rego doesn’t operate with just true and false. There’s a third state lurking underneath: undefined. And under negation, undefined behaves exactly like false. The system doesn’t distinguish between “we know the user is not blocked” and “we don’t know whether the user is blocked.” In both cases, access is granted.
That’s a very different guarantee than most people think they’re writing. What looks like a simple rule — “allow unless blocked” — quietly becomes: allow unless you can prove the user is blocked . And that includes situations where the system is missing data, missing rules, or partially broken.
In contrast, Datalog doesn’t have this ambiguity. If something cannot be derived, it is simply false. There is no third state. Negation operates over a closed world: either a fact is known, or it isn’t. That makes the behavior stable — missing information doesn’t silently change the meaning of your program.
Rego, by comparison, lives in a more open world. And in that world, “you couldn’t prove it” quietly becomes “it’s allowed”. Thus the burden shifts to policy writers.
In Rego, correctness isn’t just about writing the right rules. It’s about deciding what should happen when the system doesn’t know.
Should “unknown” mean allow? Deny? Fail?
Rego doesn’t enforce an answer. It leaves that decision to you. Which means you’re not just writing policy — you’re also defining how your system behaves under missing data, partial failures, and uncertainty. And unless you make that explicit, “you couldn’t prove it” quietly becomes “it’s allowed.”
The Takeaway
Rego is not a theoretically clean language. It’s a pragmatic one that solved a real problem — cloud-native policy enforcement — and solved it well. The cost is a set of formal guarantees that were quietly left on the table.
If you’re building AI systems — LLM guardrails, agent policies, validation layers around model outputs, or enforcing constraints on tool use — Rego can be a very practical fit. It works naturally with JSON, integrates cleanly into modern pipelines, and gives you a declarative way to enforce rules without burying them in application code.
But once your system starts to look more like a graph — multi-step reasoning, nested permissions, agents calling other agents, or decisions that depend on transitive relationships — you’ll start to feel the edges. Either you keep bending the problem to fit the language, or you move parts of the logic outside of it. And when that happens, things get harder to reason about, harder to audit, and harder to trust over time.
This isn’t something you notice on day one. It shows up later — when your system has grown, behaviors emerge from multiple layers, and you’re trying to answer a deceptively simple question: “Why did the model do that?”
The teams that get the most out of Rego aren’t the ones who just plug it in — they’re the ones who understand where its boundaries are, and design with those boundaries in mind from the start.
Remember there is a difference between using a tool and actually knowing it.
메타데이터
- post_id
- 09a17e1b1f91
- slug
- rego-is-good-but-it-comes-at-a-cost-09a17e1b1f91
- url
- https://medium.com/@aneesulmehdi/rego-is-good-but-it-comes-at-a-cost-09a17e1b1f91
- canonical_url
- https://medium.com/@aneesulmehdi/rego-is-good-but-it-comes-at-a-cost-09a17e1b1f91
- author_url
- https://medium.com/@aneesulmehdi
- status
- ok
- fetched_at
- 2026-06-20 20:29:01