At Oso, we built our own declarative logic programming language, Polar, to power fine-grained authorization in applications. Polar is inspired by Datalog, but is specialized for application authorization. It supports rich data types, and permission-oriented semantics that allow it to express sophisticated authorization logic with nested conditions, dynamic inputs, and fine-grained rules.
But this power comes with a cost. Unlike imperative code, where you can print variables or step through execution line by line, Polar asks you to describe what should be true, not how to compute it. That makes it expressive, but also challenging to debug. There’s no stack trace or instruction pointer. When something goes wrong, developers must trace logic across rules, facts, and relationships.
We couldn’t just serialize an execution trace. We needed a debugger.
This post is about how we built one: an interactive debugger for a logic language with recursion, backtracking, and custom predicates.
The Why
While working with our customers, our engineers realized that one of the most common issues during Oso Cloud integration was diagnosing an unexpected authorization result. The user must figure out why this happened (e.g. was it a problem with the data or with the policy?) and what must be changed in the implementation to produce the correct result. The best tool we provided at the time for debugging this problem was Explain in the Cloud UI.
What it did well is that it connected the decision to both the relevant data and some relevant lines in the policy that produced the result, typically the “leaf node” rules that directly matched the query. However, it didn’t surface the full chain of intermediate rule calls that led there, which made it harder to trace how the decision was actually made.
When debugging complex policies, our engineers found that those intermediate steps were often where things went wrong:
For example, given a policy:
allow(user, action, resource) if has_permission(user, action, resource); has_permission(user, "read", doc: Document) if has_role(user, "reader", doc); has_role(user, role, resource) if ...;
The Explain tool would only show the result of has_role(...), not the fact that allow(...) called has_permission(...), which in turn called has_role(...).
This limitation made it difficult to debug complex policies with nested or recursive rule logic. Imagine trying to debug your application with just the last entry in each stack trace. So we scoped a new tool —the Policy Debugger— as part of the Oso Migrate CLI. It evolved from Explain, but supported a complete view of the decision path: how the engine tried multiple rules, which ones succeeded or failed, and where the logic broke down. Crucially, it also supported point-in-time snapshots of all authorization data —both centralized in Oso Cloud and local to the application—enabling reproducible, accurate debugging.
This foundation was critical to making it useful during migrations, where reproducing and debugging specific decisions requires full visibility into the exact state of both data and policy at the time of evaluation.
The Challenge: Debugging Logic, as Declarative Code
Imperative debuggers (like in Python or JavaScript) rely on flattened execution. Tools like the VSCode debugger give you familiar controls: step over, step into, breakpoint here.
But none of that applies to logic programming. Logic languages like Polar don't evaluate linearly. Instead, execution is a branching proof tree, with backtracking, recursion, and multiple possible evaluation paths all happening under the hood.
To answer a query like:
has_permission(User{"alice"}, "read", Foo{"bar"})
Polar might:
- Match any rule called has_permission that has three parameters
- Traverse the body of each rule, which may call other rules (has_role, has_relation, etc.)
- Expand into recursive relationships or data fetches
The result is a dynamic, branching tree of possibilities. This made our problem fundamentally different from traditional debugging. We couldn’t reuse the usual abstractions. We had to invent a new model of interactive debugging tailored to how logic evaluation actually works.
Explaining the Structure: Alternating ORs and ANDs
Under the hood, Polar evaluates authorization queries using a proof model that alternates between:
- OR nodes: the various ways Oso could satisfy a query. I.e., “Any of these rules could match.”
- AND nodes: the subqueries that must all simultaneously be true for a specific rule to apply. I.e., “All of these conditions must hold.”
For example, evaluating has_permission(...) might involve trying multiple rules (OR), each of which depends on conditions like has_role(...) and grants_permission(...) (AND).
Conceptually, this forms a tree: each node is a query that branches into alternative rule matches, with each branch requiring several conditions to succeed together.
Consider the following policy:
actor User {} resource Foo { permissions = ["read"]; roles = ["reader"]; "read" if "reader"; } resource Bar { permissions = ["read"]; relations = { foo: Foo }; "read" if "read" on "foo"; } resource Role {} has_permission(actor: Actor, permission: String, resource: Resource) if role matches Role and has_role(actor, role, resource) and grants_permission(role, permission);
Now suppose a user is trying to debug why the following test fails:
test "custom roles" { setup { has_role(User{"alice"}, Role{"roll"}, Foo{"foo"}); grants_permission(Role{"roll"}, "read"); } assert allow(User{"alice"}, "read", Bar{"bar"}); }
Here’s a look at how Polar internally evaluates that query, based on the logic in the policy:
subquery: allow(User{"alice"}, "read", Bar{"bar"} 🟡 way: rule (builtin) 🟡 subquery: has_permission(User{"alice"}, "read", Bar{"bar"} 🟡 way: fact ❌ way: rule@12 🟡 subquery: has_relation(Bar{"bar"}, "foo", Foo{"Foo"}) ❌ way: fact ❌ subquery: has_permission(User{"alice"}, "read", Foo{"foo"}) 🟢 way: fact ❌ way: rule@6 ❌ subquery: has_role(User{"alice"}, "reader", Foo{"foo"}) ❌ way: fact ❌ way: rule@15 🟢 subquery: has_role(User{"alice"}, Role{"roll"}, Foo{"foo"}) 🟢 way: fact 🟢 subquery: grants_permission(Role{"roll"}, "read") 🟢 way: fact 🟢 way: rule@15 ❌ subquery: has_role(User{"alice"}, role: Role, Bar{"bar"}) ❌ subquery: grants_permission(role: Role, "read") ❌
But the Policy Debugger doesn’t show this full tree at once; instead, it presents a simplified, interactive view. It’s the same underlying model, just abstracted away from this complexity and revealed incrementally to avoid the pitfalls of full expansion: massive outputs, infinite recursion, and cognitive overload.
A UI Designed for Exploration
The Policy Debugger UI is designed to mirror how users naturally explore logic, by starting with what failed and drilling down into why. The evaluation tree starts out fully collapsed. Users expand nodes selectively, focusing only on the parts of the query that didn’t behave as expected. This keeps the interface manageable and avoids overwhelming users with irrelevant branches.
For example, if a user sees that an unexpected allow(User, "read", Bar) decision was denied, they can click in to see how that result was derived, then expand subqueries like has_permission(...) or has_role(...) one at a time. At each step, they see a single logical path—the AND-conditions required for a given rule—and can cycle between alternative rules (the OR-branches) using left/right arrow keys.
This interaction model means the tree the engine evaluates and the tree the user sees are not 1:1, but that's by design. The underlying evaluation tree is rich and multidimensional; the UI presents a curated, navigable slice of it, precisely the part the user is interested in at that moment.
Want to try it yourself? Here’s how to use the Policy Debugger in Oso Migrate.
Why Not Just Pretty Print? Laziness as a Feature
Debugging required a shift in how the compiler worked.
Polar was built for fast, recursive evaluation, not introspection. It compiles queries into fully expanded proof trees using fixed-point recursion: great for speed, but not for traceability. In the existing Explain tool, those fixed-point results show up as opaque internal references, making it difficult to follow how a decision was reached.
To support interactive debugging, we had to rethink this model entirely. Rather than precomputing the full tree, we treated it as a lazy data structure, expanding one step at a time in response to user input. This was essential: many real-world policies contain deep nesting or infinite recursion, making full expansion impractical or impossible.
As Oso engineer Vijay Ramamurthy put it:
If you tried to create the entire tree up front, most policies people use with Oso would just loop infinitely and never actually produce a complete tree. So the tree has to be a different kind of data structure. The technical term is coinductive—the more familiar way to think about it is lazy or on demand. [...] We had to make the compiler interact with the debugger dynamically, responding to user input in real time. When a user expands a node in the debugger, that action often invokes the compiler to expand that specific part of the tree. This back-and-forth, on-demand expansion is necessary to handle the inherently infinite nature of many policies.This meant rearchitecting core parts of the engine to support:
- One-step rule expansion: Expand a single layer at a time.
- On demand evaluation: Wait for user input before expanding more.
- Compiler interaction in real-time: Each expansion step triggers the Polar compiler to evaluate exactly that part of the query (with scoped input), rather than precomputing the entire tree.
- Suspended recursion: Avoid tabling artifacts and loops in recursive policies.
- Lazy tree nodes: No eager computation or rendering.
All of this required deep integration between the debugger UI and the Polar compiler.
Why a Terminal UI (TUI, reads like Ratatouille)?
We built the Policy Debugger as a terminal-based UI (TUI) to keep it tightly integrated with the Polar compiler, which is written in Rust. To support features like lazy evaluation, recursive stepping, and real-time interaction, the debugger needed to communicate directly with the internal Rust state. A web-based GUI would have required adding complex serialization layers (e.g., via HTTP or WebSockets) to bridge between Rust and JavaScript —adding latency, brittleness, and architectural overhead we wanted to avoid.
Instead, by building a TUI using the ratatui library, we could:
- Embed the UI directly in the same process as the compiler.
- Avoid the friction of packaging or hosting a web app.
- Prototype and iterate quickly while maintaining full control over application state, rendering, and compiler interaction.
This wasn’t just a developer convenience, it matched how the tool is actually used. Most users of the Policy Debugger run it locally, alongside their app and policy code, often from within a terminal or IDE. A TUI made it possible to debug and edit policy in the same workflow, without switching contexts.
We explored GUI options as well—like building a web app served from a local server, or packaging a desktop app using Tauri or Electron—but each came with tradeoffs:
- Web apps would require serializing complex Rust structures, breaking the tight feedback loop we needed.
- Tauri offered better Rust integration, but added installation friction and user experience constraints.
- Electron was heavier than we needed, and would have required non-trivial bridging to interact with Rust.
Ultimately, none of those options allowed us to move as quickly, or stay as close to the engine, as a Rust-native terminal UI.
How Developers Use It
Early versions of the Policy Debugger already allow developers to visualize how a policy is evaluated, step through recursive rules, and debug complex logic flows: all directly from the command line.
The typical workflow looks like this:
- Run your app with Oso Migrate enabled
- Log actual vs. expected authorization decisions
- Click into mismatches and launch the debugger to visualize
- Interactively expand through your policy logic
- See exactly where and why the failure happened
This flow saves time, reduces guesswork, and replaces tedious mental tracing with a structured, visual model of the decision logic. It’s particularly helpful for debugging deep or recursive policies that would otherwise be opaque without step-by-step introspection.
Beyond Debugging: Snapshotting and Parity Checks
The debugger is just one piece of the broader Oso Migrate toolkit. We also built:
- Parity API: Compare decisions from your legacy system with Oso by specifying expected results; Oso Migrate flags mismatches in the logs to help catch drift before switching enforcement.
- Data snapshotting: Every logged request includes a full snapshot of the facts used—both from Oso Cloud and local Postgres—so you can debug or test decisions exactly as they happened.
- Copy to test: Convert any request in the Logs tab into a .polar test case, including the data snapshot and expected result, to quickly codify real behavior into reusable tests.
- Request replay: Re-run past authorization requests with updated policies to verify fixes or detect regressions, no need to reproduce the request manually.
- Test runner: Execute and auto-re-run all .polar tests on policy changes. Failed assertions can be inspected interactively in the Policy Debugger.
Together, these tools let you confidently integrate Oso into your stack, without needing to become an expert in logic programming.
Final Thoughts
Debugging logic languages is notoriously hard. But with the Policy Debugger, we’ve built a debugger that works the way your brain does, one rule at a time, with interactive feedback, and grounded in real data.
If you’re building with Polar or thinking about how to make Datalog-style engines more usable, we’d love to hear your thoughts in our community.
The Policy Debugger is available today in the Oso Migrate CLI. If you're working with logic rules or policy evaluation, give it a try, and let us know what you think.