intermediate 20 min read troubleshooting-hub Updated: 2025-10-11

Fixing 'Undefined Decision' Errors in OPA Rego Policies (2025)

A comprehensive troubleshooting guide for OPA Rego undefined decision errors. Learn why decisions are undefined, how to fix them, and best practices for robust policy writing.

Quick Diagnosis

If your policy returns undefined or {}, it means:

  • No rule body evaluated to true
  • Missing default declaration
  • Input data doesn't match expected structure

Understanding Undefined Decisions in Rego

In Rego, a decision is undefined when no rule successfully evaluates to produce a value. This is different from returning false - undefined means "no answer" rather than "the answer is false".

What Causes Undefined?

No Matching Rules

All rule bodies failed (evaluated to false), so no value was produced.

🎯

Missing Default

No default declared, and no rule matched.

📊

Data Path Issues

Referenced data doesn't exist in input/data.

Example: Undefined Decision

Broken Policy

package example

# No default declared
allow {
    input.user == "admin"  # This is the ONLY rule
}

Input

{
  "user": "guest"
}

Result

$ opa eval -d policy.rego -i input.json "data.example.allow"
{}  # Undefined - no value returned

Why? The rule body input.user == "admin" is false (because user is "guest"), so the rule doesn't fire. With no default and no matching rules, the decision is undefined.

Common Causes of Undefined Decisions

1. Missing Default Value

Problem

package authz

allow {
    input.role == "admin"
}

# If input.role is NOT "admin", result is undefined

2. Rule Conditions Never Match

Problem

package validation

valid_tags {
    # Expecting array, but input.tags is a string
    input.tags[_] == "production"
}

3. Incorrect Input Path

Problem

package check

result {
    # Typo: should be input.config.enabled
    input.configuration.enabled == true
}

4. Type Mismatches

Problem

package compare

match {
    # input.count is string "5", comparing to integer
    input.count > 3
}

Fix: Missing Default Values

Always declare a default value for your policy decisions.

Solution: Add Default Declaration

Before (Undefined)

package authz

allow {
    input.role == "admin"
}

After (Fixed)

package authz

# Default to false if no rules match
default allow := false

allow {
    input.role == "admin"
}

Test

# Input
{ "role": "guest" }

# Before: undefined
# After: false (because of default)

When to Use Different Defaults

Default to false (Deny by Default)

default allow := false

allow {
    input.user == "admin"
}

Use for: Authorization (deny unless explicitly allowed)

Default to true (Allow by Default)

default deny := true

deny {
    input.contains_malware
}

Use for: Validation (pass unless violation found)

Default to empty set/array

default violations := []

violations[msg] {
    input.resources[i].unencrypted
    msg := sprintf("Resource %v not encrypted", [i])
}

Use for: Collecting multiple results

Default to object

default result := {"allowed": false, "reason": "no match"}

result := {"allowed": true, "reason": "admin"} {
    input.role == "admin"
}

Use for: Structured responses with metadata

Fix: Rule Never Matches

Sometimes your rule conditions are too strict or check the wrong thing.

Problem: Overly Restrictive Conditions

Broken Policy

package validation

valid_environment {
    input.environment == "production"
    input.region == "us-east-1"
    input.encrypted == true
    input.backup_enabled == true
    # If ANY condition is false, rule fails
}

Solution 1: Use Default + Multiple Rules

Fixed with OR Logic

package validation

default valid_environment := false

# Multiple rules act as OR
valid_environment {
    input.environment == "production"
    input.encrypted == true
}

valid_environment {
    input.environment == "staging"
    input.backup_enabled == true
}

Solution 2: Add Debugging with trace()

Debug Why Rule Fails

package debug_example

allow {
    trace(sprintf("Checking user: %v", [input.user]))
    input.user == "admin"
    
    trace(sprintf("Checking role: %v", [input.role]))
    input.role == "superadmin"
    
    trace("All conditions passed!")
}

# Run with --explain=notes to see trace output
# opa eval -d policy.rego -i input.json --explain=notes "data.debug_example.allow"

Solution 3: Check Data Existence First

Safer Pattern

package safe_check

default result := false

result {
    # Check field exists before comparing
    input.tags
    input.tags[_] == "production"
}

# Or use negation to check non-existence
result {
    not input.disable_check  # Only if field doesn't exist or is false
}

Fix: Incorrect Data Path

Typos or wrong assumptions about input structure cause undefined results.

Problem: Wrong Path

Broken Policy

package access

allow {
    # Assumes input.user.permissions exists
    "admin" in input.user.permissions
}

# Input (different structure)
# {
#   "user": {
#     "roles": ["admin"]  # Not "permissions"
#   }
# }

Solution: Validate Input Structure

Method 1: Check Field Exists

package access

default allow := false

allow {
    input.user           # Check user exists
    input.user.roles     # Check roles exists
    "admin" in input.user.roles
}

Method 2: Use object.get() with Default

package access

default allow := false

allow {
    roles := object.get(input, ["user", "roles"], [])
    "admin" in roles
}

Method 3: Print Input for Debugging

# In your policy
package debug

result {
    trace(sprintf("Full input: %v", [input]))
    input.user
}

# Run to see what input looks like
opa eval -d policy.rego -i input.json --explain=notes "data.debug.result"

Common Path Mistakes

Wrong Correct
input.user_name input.userName (camelCase)
input.tags.environment input.tags["environment"] (map access)
data.resources input.resources (wrong source)
input[0].name input.items[0].name (array in object)

Fix: Type Mismatches

Comparing different types (string vs number, string vs boolean) causes rules to fail.

Problem: String vs Integer

Broken Policy

package comparison

exceeds_limit {
    # input.count is "5" (string), comparing to integer
    input.count > 3
}

Solution: Convert Types

Convert String to Integer

package comparison

default exceeds_limit := false

exceeds_limit {
    count := to_number(input.count)
    count > 3
}

# Or check if it's already a number
exceeds_limit {
    is_number(input.count)
    input.count > 3
}

Convert String to Boolean

package bool_check

is_enabled {
    # If string "true" or "false"
    lower(input.enabled) == "true"
}

# Or check actual boolean
is_enabled {
    input.enabled == true
}

Safe Type Checking Pattern

package safe_types

default result := false

# Handle multiple possible types
result {
    is_number(input.value)
    input.value > 100
}

result {
    is_string(input.value)
    to_number(input.value) > 100
}

Type Checking Functions

Function Description Example
is_number(x) Check if x is a number is_number(5) = true
is_string(x) Check if x is a string is_string("hello") = true
is_boolean(x) Check if x is boolean is_boolean(true) = true
is_array(x) Check if x is an array is_array([1,2,3]) = true
is_object(x) Check if x is an object is_object({"a":1}) = true
to_number(x) Convert string to number to_number("42") = 42

Best Practices for Robust Policies

1. Always Use Defaults

# BAD: No default
allow {
    input.user == "admin"
}

# GOOD: Explicit default
default allow := false

allow {
    input.user == "admin"
}

2. Validate Input Structure

package validation

# Helper to check required fields
has_required_fields {
    input.user
    input.resource
    input.action
}

# Use in your rules
default allow := false

allow {
    has_required_fields
    input.user == "admin"
}

3. Use Helper Rules for Clarity

package helpers

# BAD: Complex inline logic
allow {
    input.user.role == "admin"
    input.user.department == "engineering"
    input.resource.sensitivity != "high"
}

# GOOD: Named helper rules
default allow := false

is_admin {
    input.user.role == "admin"
}

is_engineer {
    input.user.department == "engineering"
}

is_low_sensitivity {
    input.resource.sensitivity != "high"
}

allow {
    is_admin
    is_engineer
    is_low_sensitivity
}

4. Write Comprehensive Tests

Test for Undefined Cases

# policy_test.rego
package authz_test

import data.authz

# Test explicit allow
test_admin_allowed {
    authz.allow with input as {"user": "admin"}
}

# Test explicit deny
test_guest_denied {
    not authz.allow with input as {"user": "guest"}
}

# Test missing input doesn't cause undefined
test_empty_input_denied {
    not authz.allow with input as {}
}

# Test malformed input
test_malformed_input {
    not authz.allow with input as {"wrong_field": "value"}
}

Run Tests

opa test . -v

5. Use Fail-Safe Patterns

Pattern: Check Before Access

package safe

default result := false

# BAD: Can cause undefined
result {
    input.config.enabled == true
}

# GOOD: Check existence first
result {
    input.config        # Check config exists
    input.config.enabled == true
}

# BETTER: Use object.get with default
result {
    enabled := object.get(input, ["config", "enabled"], false)
    enabled == true
}

6. Document Expected Input

# policy.rego
package authz

# Expected input structure:
# {
#   "user": {
#     "id": string,
#     "role": string,
#     "department": string
#   },
#   "resource": {
#     "type": string,
#     "owner": string
#   }
# }

default allow := false

allow {
    input.user.role == "admin"
}

Quick Reference: Debugging Undefined Decisions

Symptom Likely Cause Fix
Result is {} No default, no rule matched Add default rulename := value
Rule never fires Conditions always false Use trace() to debug, check input
Field access fails silently Wrong path or typo Validate structure, use object.get()
Comparison doesn't work Type mismatch Use is_number(), to_number()
Array iteration fails Not actually an array Check with is_array()