The Problem Statement

Our ReBAC v2 model from Post 4 is powerful—it handles hierarchies, inheritance, and complex boolean combinations of relations. But it has a critical limitation: all permissions are static. Once you grant Alice access to a document, she has it forever (or until you explicitly revoke it).

Real-world systems need context-aware permissions:

"Alice can view the report, but only during business hours (9 AM to 5 PM)." "Bob can access the database from the office network, but not from home." "Carol can edit the document, but only until December 31st."

These aren't new relations or roles—they're conditions on existing permissions. The permission exists, but it's only valid under certain circumstances.

The core problem: Our current model can't express "this permission is granted, but only if X is true right now."

Why Post 4's Model Isn't Enough

Let's see what goes wrong if we try to solve this with Post 4's tools:

Approach 1: Create Time-Based Roles

// Try to create a role for "users who are currently in business hours"
store.Add(RelationTuple{
    Resource: &ObjectAndRelation{Namespace: "role", ObjectID: "business_hours_users", Relation: "member"},
    Subject:  &DirectSubject{Object: &ObjectRef{Namespace: "user", ObjectID: "alice"}},
})

// Then grant access to that role
store.Add(RelationTuple{
    Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
    Subject:  &SubjectSet{
        Object: &ObjectAndRelation{Namespace: "role", ObjectID: "business_hours_users", Relation: "member"},
    },
})

Problem: This tuple is static. Alice is either in the role or not. We'd need to:

  • Add her at 9 AM

  • Remove her at 5 PM

  • Repeat every day

This is write-heavy, error-prone, and doesn't scale. You'd need background jobs constantly updating tuples.

Approach 2: Application-Layer Checks

// Grant Alice access unconditionally
store.Add(RelationTuple{
    Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
    Subject:  &DirectSubject{Object: &ObjectRef{Namespace: "user", ObjectID: "alice"}},
})

// Then check time in application code
if isBusinessHours(time.Now()) {
    // Allow access
} else {
    // Deny access
}

Problem: Authorization logic leaks into the application. Different services might implement the check differently. You lose the single source of truth for authorization.

The Core Insight: Caveats

Instead of storing time-based roles or checking conditions in application code, we introduce caveats: conditions attached to permissions.

A caveat is:

  • Defined once in the schema (e.g., "business_hours")

  • Attached to tuples when granting access (e.g., "Alice can view this document [business_hours]")

  • Evaluated at check time with runtime context (e.g., "Is it currently 9-5?")

This keeps authorization logic centralized, testable, and deterministic.

Attribution: Caveats are inspired by Zanzibar's "caveated subjects" pattern, which enables conditional permissions in Google's authorization system.

Extending the Model: Caveats

Step 1: Caveat Definition (Schema-Level)

A caveat is defined in the schema with:

  • Name: Unique identifier (e.g., "business_hours")

  • Parameters: Expected runtime values (e.g., "now_utc", "tz")

  • Expression: Boolean logic to evaluate (e.g., "local_hour(now_utc, tz) >= 9 AND local_hour(now_utc, tz) < 17")

type CaveatDefinition struct {
    Name       string                    // "business_hours"
    Parameters []CaveatParameter         // [now_utc: timestamp, tz: string]
    Expression *BooleanExpr              // local_hour(now_utc, tz) >= 9 AND < 17
    Metadata   map[string]any            // Optional metadata
}

// CaveatParameter is an interface for type-safe parameter definitions
type CaveatParameter interface {
    GetName() string
    GetType() CaveatParameterType
}

// ScalarParameter represents simple scalar types (bool, int, string, etc.)
type ScalarParameter struct {
    Name string
    Type CaveatParameterType
}

func (p *ScalarParameter) GetName() string { return p.Name }
func (p *ScalarParameter) GetType() CaveatParameterType { return p.Type }

// ListParameter represents a list with a specific element type
type ListParameter struct {
    Name        string
    ElementType CaveatParameterType
}

func (p *ListParameter) GetName() string { return p.Name }
func (p *ListParameter) GetType() CaveatParameterType { return CAV_T_LIST }

type MapParameter struct {
    Name      string
    KeyType   CaveatParameterType
    ValueType CaveatParameterType
}

func (p *MapParameter) GetName() string { return p.Name }
func (p *MapParameter) GetType() CaveatParameterType { return CAV_T_MAP }

type CaveatParameterType int32

const (
    CAV_T_BOOL CaveatParameterType = iota
    CAV_T_INT
    CAV_T_UINT
    CAV_T_DOUBLE
    CAV_T_STRING
    CAV_T_BYTES
    CAV_T_DURATION
    CAV_T_TIMESTAMP
    CAV_T_LIST    // Use ListParameter to specify element type
    CAV_T_MAP     // Use MapParameter to specify key/value types
)

Type-safe parameter definitions:

The interface-based design ensures compile-time type safety:

// Scalar parameters (simple types)
&ScalarParameter{Name: "now_utc", Type: CAV_T_TIMESTAMP}
&ScalarParameter{Name: "tz", Type: CAV_T_STRING}
&ScalarParameter{Name: "current_hour", Type: CAV_T_INT}

// List parameter with element type
&ListParameter{
    Name:        "allowed_ips",
    ElementType: CAV_T_STRING,  // List of strings
}

// Map parameter with key and value types
&MapParameter{
    Name:      "user_quotas",
    KeyType:   CAV_T_STRING,    // String keys
    ValueType: CAV_T_INT,       // Integer values
}

Why this design?

  1. Type safety at compile time: You can't create a ListParameter without specifying ElementType

  2. Clear intent: ListParameter vs ScalarParameter makes the type structure explicit

  3. Extensibility: Easy to add new parameter types (e.g., StructParameter, UnionParameter)

  4. No invalid states: Can't accidentally set Elem on a scalar or forget it on a list

Type safety for CMP_IN operator:

The CMP_IN operator requires type compatibility:

  • elem(Type X) IN list(Type X) — element type must match list element type

  • key(Type X) IN map(Type X, Type Y) — key type must match map key type

Example:

// Valid: string IN list<string>
Predicate{
    Left:  &IdentifierExpr{Name: "request_ip"},      // Type: STRING
    Op:    CMP_IN,
    Right: &IdentifierExpr{Name: "allowed_ips"},     // Type: LIST<STRING>
}

// Invalid: int IN list<string> → schema validation error
Predicate{
    Left:  &LiteralExpr{Value: 42, Type: CAV_T_INT},
    Op:    CMP_IN,
    Right: &IdentifierExpr{Name: "allowed_ips"},     // Type: LIST<STRING>
}

Expressions

Before we build the full caveat expression, we need to understand the building blocks: caveat expressions. These are the atomic units that make up predicates and boolean logic.

High-level structure:

  • BooleanExpr: Top-level boolean expression (AND, OR, NOT, or a single predicate)

  • Predicate: Comparison between two expressions (e.g., current_hour >= 9)

  • CaveatExpr: Atomic expressions (Identifier, Literal, or Call)

There are three types of caveat expressions, all implementing the CaveatExpr interface:

1. IdentifierExpr — Parameter Reference

An identifier references a parameter from the caveat context (runtime values).

type IdentifierExpr struct {
    Name string  // e.g., "current_hour"
}

Purpose: Look up a runtime value by name.

Example:

&IdentifierExpr{Name: "current_hour"}
// At evaluation time, this looks up the value of "current_hour" from the context
// If context has {current_hour: 14}, this evaluates to 14

Use cases:

  • Comparing against runtime values: "Is the current hour >= 9?"

  • Matching user attributes: "Does user_department match?"

  • Checking request properties: "Is request_ip in allowed_ips?"

2. LiteralExpr — Constant Value

A literal is a constant value with an explicit type. This is crucial for safety.

type LiteralExpr struct {
    Value any                  // e.g., 9
    Type  CaveatParameterType  // e.g., CAV_T_INT
}

Purpose: Represent a constant value with compile-time type checking.

Example:

&LiteralExpr{Value: 9, Type: CAV_T_INT}
// This is the constant 9 (as an integer)

&LiteralExpr{Value: "office", Type: CAV_T_STRING}
// This is the constant "office" (as a string)

&LiteralExpr{Value: []string{"US", "CA"}, Type: CAV_T_LIST}
// This is a list of strings

Why typed literals?

  • Type safety: Prevents comparing apples to oranges (e.g., "9" string vs 9 integer)

  • Injection prevention: No free-form strings that could be interpreted as code

  • Compile-time validation: Schema validation catches type mismatches early

  • Determinism: Same literal always evaluates the same way

3. CallExpr — Function Calls (Future Extensibility)

A call expression invokes a function with arguments. This is reserved for future use (Post 7+).

type CallExpr struct {
    FnName string       // e.g., "now"
    Args   []CaveatExpr // Arguments (can be IdentifierExpr, LiteralExpr, or nested CallExpr)
}

Purpose: Support extensible functions like now(), contains(), startsWith(), etc.

Example (Post 7+):

&CallExpr{
    FnName: "now",
    Args:   []CaveatExpr{},
}
// Evaluates to the current timestamp

&CallExpr{
    FnName: "contains",
    Args: []CaveatExpr{
        &IdentifierExpr{Name: "allowed_ips"},
        &IdentifierExpr{Name: "request_ip"},
    },
}
// Checks if allowed_ips contains request_ip

Why separate CallExpr?

  • Enables extensibility without changing the core model

  • Allows composition (functions can call other functions)

  • Keeps the schema deterministic (only whitelisted functions allowed)

Important: Determinism Guarantee

All functions must be pure and parameterized—no ambient side-effects:

Bad: now() non-deterministic, depends on system clock Good: local_hour(now_utc, tz) deterministic, caller provides now_utc parameter

This ensures:

  • Same inputs always produce same outputs

  • Evaluation is reproducible for auditing

  • No hidden dependencies on system state

Why Three Types?

Type

Purpose

Example

When Used

IdentifierExpr

Reference runtime parameter

current_hour

Comparing against context values

LiteralExpr

Constant with explicit type

9 (as int)

Hardcoded thresholds, allowlists

CallExpr

Function call (future)

now()

Dynamic computations (Post 7+)

Key insight: By restricting to these three types, we prevent injection attacks and ensure deterministic evaluation. You can't sneak arbitrary code into a caveat—only parameters, constants, and whitelisted functions.

Predicate — Comparison Operations

Now that we understand the three types of caveat expressions, we need to understand how they're used in predicates. A predicate is a comparison between two expressions that evaluates to true or false.

type Predicate struct {
    Left  CaveatExpr    // Left-hand side expression
    Op    CompareOp     // Comparison operator
    Right CaveatExpr    // Right-hand side expression
}

Purpose: Compare two expressions using a comparison operator.

Supported comparison operators:

type CompareOp int32

const (
    CMP_EQ           // == (equals)
    CMP_NE           // != (not equals)
    CMP_LT           // < (less than)
    CMP_LE           // <= (less than or equal)
    CMP_GT           // > (greater than)
    CMP_GE           // >= (greater than or equal)
    CMP_IN           // in (list membership)
    CMP_STARTS_WITH  // starts_with (string prefix)
    CMP_ENDS_WITH    // ends_with (string suffix)
    CMP_CONTAINS     // contains (string substring)
)

Examples:

// current_hour >= 9
&Predicate{
    Left:  &IdentifierExpr{Name: "current_hour"},
    Op:    CMP_GE,
    Right: &LiteralExpr{Value: 9, Type: CAV_T_INT},
}

// user_department == "HR"
&Predicate{
    Left:  &IdentifierExpr{Name: "user_department"},
    Op:    CMP_EQ,
    Right: &LiteralExpr{Value: "HR", Type: CAV_T_STRING},
}

// request_ip IN allowed_ips
&Predicate{
    Left:  &IdentifierExpr{Name: "request_ip"},
    Op:    CMP_IN,
    Right: &IdentifierExpr{Name: "allowed_ips"},
}

Why predicates matter:

  • Type safety: Both sides must have compatible types for the comparison

  • Deterministic: Same inputs always produce the same result

  • Composable: Predicates are combined using boolean logic (AND, OR, NOT)

Example: Business Hours Caveat (Using CaveatExpr Types)

Now let's see how the three CaveatExpr types work together in a real caveat:

businessHoursCaveat := &CaveatDefinition{
    Name: "business_hours",
    Parameters: []CaveatParameter{
        &ScalarParameter{Name: "now_utc", Type: CAV_T_TIMESTAMP},  // UTC timestamp
        &ScalarParameter{Name: "tz", Type: CAV_T_STRING},          // IANA timezone (e.g., "America/New_York")
    },
    Expression: &BooleanExpr{
        Op: BOOL_AND,  // Boolean logic: AND two predicates
        Children: []*BooleanExpr{
            // Predicate 1: local_hour(now_utc, tz) >= 9
            {
                Op: BOOL_PREDICATE,
                Pred: &Predicate{
                    Left: &CallExpr{  // ← CallExpr: pure function call
                        FnName: "local_hour",
                        Args: []CaveatExpr{
                            &IdentifierExpr{Name: "now_utc"},
                            &IdentifierExpr{Name: "tz"},
                        },
                    },
                    Op:    CMP_GE,
                    Right: &LiteralExpr{Value: 9, Type: CAV_T_INT},
                },
            },
            // Predicate 2: local_hour(now_utc, tz) < 17
            {
                Op: BOOL_PREDICATE,
                Pred: &Predicate{
                    Left: &CallExpr{  // ← CallExpr: pure function call
                        FnName: "local_hour",
                        Args: []CaveatExpr{
                            &IdentifierExpr{Name: "now_utc"},
                            &IdentifierExpr{Name: "tz"},
                        },
                    },
                    Op:    CMP_LT,
                    Right: &LiteralExpr{Value: 17, Type: CAV_T_INT},
                },
            },
        },
    },
}

// Note: local_hour is a pure, whitelisted function that:
// - Takes UTC timestamp and IANA timezone string
// - Returns the hour (0-23) in the specified timezone
// - Is deterministic: same inputs always produce same output
// - Handles DST transitions correctly

Breaking it down:

  • CallExpr (local_hour(now_utc, tz)): Pure function that converts UTC to local hour

  • IdentifierExpr (now_utc, tz): Looks up runtime values from context

  • LiteralExpr (9, 17): Hardcoded constants with explicit types

  • Predicate: Compares two CaveatExpr using a CompareOp

  • BooleanExpr: Combines predicates with boolean logic (AND, OR, NOT)

At evaluation time:

  • If context has {now_utc: 1640000000, tz: "America/New_York"}:

    • local_hour(1640000000, "America/New_York") → 14

    • 14 >= 9 AND 14 < 17 → TRUE

  • If context has {now_utc: 1640000000, tz: "America/Los_Angeles"}:

    • local_hour(1640000000, "America/Los_Angeles") → 11

    • 11 >= 9 AND 11 < 17 → TRUE

  • If context missing now_utc or tz: REQUIRES_CONTEXT (missing parameter)

Why this approach is better:

  • Timezone-aware: Handles different timezones correctly

  • DST-safe: local_hour function handles daylight saving transitions

  • Deterministic: Same UTC timestamp + timezone always produces same result

  • Testable: Can test with specific timestamps, not dependent on system clock

Performance characteristics:

  • Time complexity: O(n) where n = number of nodes in expression tree

  • Space complexity: O(d) where d = maximum depth of expression tree

  • Evaluation cost: ~1-10μs for typical business rules (measured on modern hardware)

  • Optimization: Short-circuit evaluation stops on first FALSE in AND, first TRUE in OR

Step 2: Schema-Level Caveats (Relation Constraints)

Relations can require caveats at the schema level, applying to all grants of that relation:

type SubjectConstraint struct {
    RequiredCaveat *CaveatRef  // Caveat that must be satisfied
}

type CaveatRef struct {
    CaveatName string
    Context    *CaveatContext  // Optional pre-bound values
}

type NamespaceRelationDefinition struct {
    Name            string
    AllowedSubjects []AllowedSubject  // From Post 4
    Constraints     []SubjectConstraint  // NEW: Schema-level caveat requirements
    // ... other fields from Post 4
}

Example: All viewers must satisfy business_hours

documentNamespace := &NamespaceDefinition{
    Name: "document",
    Relations: []*NamespaceRelationDefinition{
        {
            Name: "viewer",
            AllowedSubjects: []AllowedSubject{
                {Namespace: "user", Relation: ""},
            },
            Constraints: []SubjectConstraint{
                {
                    RequiredCaveat: &CaveatRef{
                        CaveatName: "business_hours",
                    },
                },
            },
        },
    },
}

Combination rule: Schema-level caveats AND tuple-level caveats are combined:

  • If schema requires business_hours AND tuple has ip_allowlist

  • Effective caveat = business_hours AND ip_allowlist

  • Both must evaluate to TRUE for access to be granted

Step 3: Contextualized Caveat (Tuple-Level)

When granting access, you can attach additional caveats to specific tuples:

type ContextualizedCaveat struct {
    CaveatName string         // Reference to CaveatDefinition
    Context    *CaveatContext // Optional pre-bound values
}

type CaveatContext struct {
    Values     map[string]any     // Parameter name → value
    Protected  map[string]bool    // Which keys are protected from override
}

Example: Grant Alice Access with Business Hours Caveat

store.Add(RelationTuple{
    Resource: &ObjectAndRelation{
        Namespace: "document",
        ObjectID:  "report_2024",
        Relation:  "viewer",
    },
    Subject: &DirectSubject{
        Object: &ObjectAndRelation{
            Namespace: "user",
            ObjectID:  "alice",
        },
        Caveat: &ContextualizedCaveat{
            CaveatName: "business_hours",
            Context:    nil, // No pre-bound values; provided at check time
        },
    },
})

Step 4: Tri-State Caveat Evaluation

Caveats evaluate to three states, not just true/false:

type CaveatState int32

const (
    CAVEAT_FALSE CaveatState = iota
    CAVEAT_TRUE
    CAVEAT_REQUIRES_CONTEXT
)

type CaveatEvalResult struct {
    State         CaveatState
    MissingParams []string  // Which parameters were missing
}

Why three states?

  1. CAVEAT_TRUE: All parameters provided, expression evaluates to true → Grant access

  2. CAVEAT_FALSE: All parameters provided, expression evaluates to false → Deny access

  3. CAVEAT_REQUIRES_CONTEXT: Some parameters missing → Can't decide yet

The third state is crucial. If a caveat requires "now_utc" but the check request doesn't provide it, we can't evaluate the caveat. We return REQUIRES_CONTEXT to signal that the caller must provide more information.

Step 5: Context Provenance & Security

Critical security consideration: Not all context values should be settable by application callers. Some values must come from trusted sources to prevent spoofing.

Context value categories:

  1. Identity claims (populated by authentication layer):

    • user.department, user.clearance_level, user.role

    • Source: Identity provider (IdP) or authentication service

    • Protection: Application callers cannot override these values

    • Example: After authentication, the auth layer injects verified user attributes

  2. Request context (injected by trusted gateway):

    • request.ip, request.method, request.user_agent

    • Source: API gateway or reverse proxy

    • Protection: Application callers cannot override these values

    • Example: Gateway reads client IP from TCP connection, not from headers

  3. Resource attributes (provided by application):

    • document.classification, document.required_department

    • Source: Application layer (fetched from database)

    • Protection: Application is responsible for accuracy

    • Example: App fetches document metadata and provides it

  4. Temporal context (provided by system):

    • now_utc, tz

    • Source: System clock or trusted time service

    • Protection: Application can provide for testing, but production uses system time

    • Example: Authorization service injects current UTC timestamp

Implementation pattern:

type CaveatContext struct {
    Values map[string]any
    Protected map[string]bool  // Which keys are protected from override
}

// Identity layer sets protected values
func (ctx *CaveatContext) SetProtected(key string, value any) {
    ctx.Values[key] = value
    ctx.Protected[key] = true
}

// Application layer can only set unprotected values
func (ctx *CaveatContext) SetValue(key string, value any) error {
    if ctx.Protected[key] {
        return fmt.Errorf("cannot override protected key: %s", key)
    }
    ctx.Values[key] = value
    return nil
}

Security guarantee: Caveats that reference identity claims (e.g., user.department) can trust that these values came from the authentication layer, not from a potentially malicious caller.

Testing Conditional Access

Note: All scenarios below use the namespace definition from Post 4 (document and folder namespaces), extended with caveats. We'll test how caveat evaluation works in different scenarios.

Scenario 1: Business Hours Access (Happy Path)

Setup:


// Define business_hours caveat in schema
schema.AddCaveat(businessHoursCaveat)

// Grant Alice access with business_hours caveat
store.Add(RelationTuple{
    Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
    Subject: &DirectSubject{
        Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"},
        Caveat: &ContextualizedCaveat{CaveatName: "business_hours"},
    },
})

Check at 2 PM EST (14:00):

ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640023200))  // 2021-12-20 14:00:00 EST (19:00:00 UTC)
ctx.SetValue("tz", "America/New_York")

result := Check(
    &ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
    &DirectSubject{Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"}},
    ctx,
)

// Result: Granted=true, CaveatState=CAVEAT_TRUE
// Reason: local_hour(1640023200, "America/New_York") = 14
//         14 >= 9 AND 14 < 17 → TRUE

Check at 8 PM EST (20:00):

ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640044800))  // 2021-12-20 20:00:00 EST (01:00:00 UTC next day)
ctx.SetValue("tz", "America/New_York")

result := Check(...)

// Result: Granted=false, CaveatState=CAVEAT_FALSE
// Reason: local_hour(1640044800, "America/New_York") = 20
//         20 >= 9 AND 20 < 17 → FALSE

Scenario 2: Missing Context (REQUIRES_CONTEXT)

Setup: Same as Scenario 1.

Check without providing required parameters:

ctx := NewCaveatContext()
// No values set

result := Check(
    &ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
    &DirectSubject{Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"}},
    ctx,
)

// Result: Granted=false, CaveatState=CAVEAT_REQUIRES_CONTEXT
// MissingParams: ["business_hours.now_utc", "business_hours.tz"]
// Reason: Can't evaluate without now_utc and tz

Caller's responsibility: The caller should:

  • Log that context was required

  • Optionally retry with context

  • Or deny access (fail-safe default)

Scenario 3: Multiple Caveats (Composition)

Setup: Grant access with both business hours AND IP allowlist requirements.

// Define IP allowlist caveat with parametric list type
ipAllowlistCaveat := &CaveatDefinition{
    Name: "ip_allowlist",
    Parameters: []CaveatParameter{
        &ScalarParameter{Name: "request_ip", Type: CAV_T_STRING},
        &ListParameter{
            Name:        "allowed_ips",
            ElementType: CAV_T_STRING,  // List of strings
        },
    },
    Expression: &BooleanExpr{
        Op: BOOL_PREDICATE,
        Pred: &Predicate{
            Left:  &IdentifierExpr{Name: "request_ip"},      // Type: STRING
            Op:    CMP_IN,
            Right: &IdentifierExpr{Name: "allowed_ips"},     // Type: LIST<STRING>
        },
    },
}

// Grant access with BOTH caveats (schema-level + tuple-level)
// Schema requires business_hours for all viewers
// Tuple adds ip_allowlist for this specific grant
store.Add(RelationTuple{
    Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "sensitive", Relation: "viewer"},
    Subject: &DirectSubject{
        Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"},
        Caveat: &ContextualizedCaveat{
            CaveatName: "ip_allowlist",
            Context: &CaveatContext{
                Values: map[string]any{
                    "allowed_ips": []string{"192.168.1.100", "10.0.0.50"},
                },
            },
        },
    },
})

Check with full context:

ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640023200))
ctx.SetValue("tz", "America/New_York")
ctx.SetValue("request_ip", "192.168.1.100")  // IP allowlist: OK

result := Check(...)

// Result: Granted=true, CaveatState=CAVEAT_TRUE
// Reason: Both business_hours AND ip_allowlist evaluate to TRUE

Check with missing IP context:

ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640023200))
ctx.SetValue("tz", "America/New_York")
// Missing request_ip

result := Check(...)

// Result: Granted=false, CaveatState=CAVEAT_REQUIRES_CONTEXT
// MissingParams: ["ip_allowlist.request_ip"]
// Reason: business_hours=TRUE AND ip_allowlist=REQUIRES_CONTEXT = REQUIRES_CONTEXT

Key insight: Multiple caveats are combined with AND logic. ALL must be satisfied for access to be granted.

Scenario 4: Wrong Parameter Type

Setup: Same as Scenario 1.

Check with wrong type:

ctx := NewCaveatContext()
ctx.SetValue("now_utc", "2021-12-20T14:00:00Z")  // String instead of timestamp

result := Check(...)

// Result: Granted=false, CaveatState=CAVEAT_FALSE, ErrorCode=ERR_TYPE_MISMATCH
// Reason: Type mismatch → deny access (fail-safe)

Scenario 5: Error Handling and Debugging

Common issues and how to handle them:

Unknown caveat name:

// Tuple references non-existent caveat
store.Add(RelationTuple{
    Subject: &DirectSubject{
        Caveat: &ContextualizedCaveat{CaveatName: "nonexistent_caveat"},
    },
})

// Result: Granted=false, CaveatState=CAVEAT_FALSE
// Reason: Unknown caveat → fail-safe deny

Malformed expression tree:

// Expression with missing predicate
badExpression := &BooleanExpr{
    Op:   BOOL_PREDICATE,
    Pred: nil, // Missing predicate!
}

// Result: Schema validation error at compile time
// Reason: Malformed expressions caught early

Debugging caveat evaluation:

// Enable detailed logging for troubleshooting
result := CheckWithOptions(resource, subject, context, &CheckOptions{
    ExplainTrace: true,  // Include evaluation trace
})

// Example trace output:
// TRACE: Evaluating caveat 'business_hours'
// TRACE: Parameter 'current_hour' = 14 (int)
// TRACE: Predicate 'current_hour >= 9' = true
// TRACE: Predicate 'current_hour < 17' = true
// TRACE: BooleanExpr 'AND' = true
// TRACE: Final result: CAVEAT_TRUE

Best practices for error handling:

  1. Validate schemas at compile time: Catch malformed expressions early

  2. Log caveat evaluation failures: Include parameter values and missing context

  3. Use structured errors: Return specific error codes for different failure modes

  4. Fail-safe defaults: Unknown caveats and type mismatches should deny access

  5. Provide clear feedback: Tell callers exactly which parameters are missing

Scenario 6: Expiration Caveat (Time-Limited Access)

Setup: Grant temporary access that expires

// Define expiration caveat
expirationCaveat := &CaveatDefinition{
    Name: "expires_at",
    Parameters: []CaveatParameter{
        &ScalarParameter{Name: "now_utc", Type: CAV_T_TIMESTAMP},
        &ScalarParameter{Name: "expires_at", Type: CAV_T_TIMESTAMP},
    },
    Expression: &BooleanExpr{
        Op: BOOL_PREDICATE,
        Pred: &Predicate{
            Left:  &IdentifierExpr{Name: "now_utc"},
            Op:    CMP_LE,  // Less than or equal
            Right: &IdentifierExpr{Name: "expires_at"},
        },
    },
}

// Grant Alice access that expires on Dec 31, 2024
store.Add(RelationTuple{
    Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "temp_report", Relation: "viewer"},
    Subject: &DirectSubject{
        Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"},
        Caveat: &ContextualizedCaveat{
            CaveatName: "expires_at",
            Context: &CaveatContext{
                Values: map[string]any{
                    "expires_at": int64(1735689600),  // 2024-12-31 23:59:59 UTC
                },
            },
        },
    },
})

Check before expiration:

ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640000000))  // 2021-12-20

result := Check(...)

// Result: Granted=true, CaveatState=CAVEAT_TRUE
// Reason: 1640000000 <= 1735689600 → TRUE

Check after expiration:

ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1736000000))  // 2025-01-04

result := Check(...)

// Result: Granted=false, CaveatState=CAVEAT_FALSE
// Reason: 1736000000 <= 1735689600 → FALSE (expired)

Scenario 7: Multi-Grant Union (Multiple Tuples with Different Caveats)

Setup: Alice has two grants with different caveats

// Grant 1: Access during business hours
store.Add(RelationTuple{
    Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
    Subject: &DirectSubject{
        Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"},
        Caveat: &ContextualizedCaveat{CaveatName: "business_hours"},
    },
})

// Grant 2: Access from office IP (anytime)
store.Add(RelationTuple{
    Resource: &ObjectAndRelation{Namespace: "document", ObjectID: "report", Relation: "viewer"},
    Subject: &DirectSubject{
        Object: &ObjectAndRelation{Namespace: "user", ObjectID: "alice"},
        Caveat: &ContextualizedCaveat{
            CaveatName: "ip_allowlist",
            Context: &CaveatContext{
                Values: map[string]any{
                    "allowed_ips": []string{"192.168.1.100"},
                },
            },
        },
    },
})

Check at 8 PM from office (Grant 1 = FALSE, Grant 2 = TRUE):

ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640044800))  // 8 PM
ctx.SetValue("tz", "America/New_York")
ctx.SetValue("request_ip", "192.168.1.100")

result := Check(...)

// Result: Granted=true, CaveatState=CAVEAT_TRUE
// Reason: Grant 1 (business_hours) = FALSE (after hours)
//         Grant 2 (ip_allowlist) = TRUE (office IP)
//         FALSE OR TRUE = TRUE (Kleene-OR aggregation)

Check at 8 PM from home (Grant 1 = FALSE, Grant 2 = FALSE):

ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640044800))  // 8 PM
ctx.SetValue("tz", "America/New_York")
ctx.SetValue("request_ip", "203.0.113.50")  // Home IP

result := Check(...)

// Result: Granted=false, CaveatState=CAVEAT_FALSE
// Reason: Grant 1 (business_hours) = FALSE (after hours)
//         Grant 2 (ip_allowlist) = FALSE (not office IP)
//         FALSE OR FALSE = FALSE

Check at 8 PM without IP context (Grant 1 = FALSE, Grant 2 = REQUIRES_CONTEXT):

ctx := NewCaveatContext()
ctx.SetValue("now_utc", int64(1640044800))  // 8 PM
ctx.SetValue("tz", "America/New_York")
// Missing request_ip

result := Check(...)

// Result: Granted=false, CaveatState=CAVEAT_REQUIRES_CONTEXT
// MissingParams: ["ip_allowlist.request_ip"]
// Reason: Grant 1 (business_hours) = FALSE (after hours)
//         Grant 2 (ip_allowlist) = REQUIRES_CONTEXT (missing IP)
//         FALSE OR REQUIRES_CONTEXT = REQUIRES_CONTEXT (might grant if IP provided)

Key insight: Multiple grants are combined with Kleene-OR. If ANY grant evaluates to TRUE, access is granted. This enables flexible access policies where users can access resources through multiple paths.

Authorization Algorithm: Extending Post 4 with Caveat Support

In Post 4, we had the Check algorithm that evaluates structural permissions (tuples + relations + edge traversal). In Post 5, we extend this to support contextual conditions through caveats.

What's New in Post 5

From Post 4, we had:

  • Check algorithm with 6 operations (DIRECT, COMPUTED_SUBJECTS, UNION, INTERSECTION, EXCLUSION, EDGE)

  • Boolean output: TRUE or FALSE

  • No context parameter

In Post 5, we add:

  • Context parameter: CaveatContext for runtime values

  • Tri-state output: TRUE, FALSE, or REQUIRES_CONTEXT

  • Caveat evaluation: At each tuple match, evaluate attached caveats

  • Caveat combination: Schema-level AND tuple-level caveats

Check Algorithm with Caveat Support

ALGORITHM: Check(subject, resource#relation, context) - With Caveat Support

Input:
  - subject: DirectSubject or SubjectSet (e.g., "user:alice")
  - resource: ObjectAndRelation (e.g., "document:1#viewer")
  - context: CaveatContext (runtime values like current_hour, request_ip)

Output:
  - CheckResult {
      Granted: bool
      CaveatState: CAVEAT_TRUE | CAVEAT_FALSE | CAVEAT_REQUIRES_CONTEXT
      MissingParams: []string  // Namespaced as "caveat_name.param"
      ErrorCode: *ErrorCode    // Optional: ERR_TYPE_MISMATCH, ERR_UNKNOWN_CAVEAT, etc.
    }

Steps:

1. Look up the relation definition in schema:
   - Find NamespaceDefinition for resource.namespace
   - Get NamespaceRelationDefinition for relation
   - Extract schema-level required caveat (if any) from SubjectConstraints

2. Check direct tuples (with aggregation):
   - Query ALL matching tuples: (subject, resource#relation)
   - For each matching tuple:
     a) Get tuple-level caveat (if any) from tuple.Subject.Caveat
     b) Compute effective caveat:
        - If schema requires caveat AND tuple has caveat:
          effectiveCaveat = SchemaCaveat AND TupleCaveat
        - If only schema requires caveat:
          effectiveCaveat = SchemaCaveat
        - If only tuple has caveat:
          effectiveCaveat = TupleCaveat
        - If neither:
          effectiveCaveat = nil
     c) Evaluate effective caveat:
        - If effectiveCaveat is nil → tupleResult = CAVEAT_TRUE
        - Else → tupleResult = EvaluateCaveat(effectiveCaveat, context, schema)

   - Combine all tuple results using Kleene-OR:
     * If ANY tuple evaluates to CAVEAT_TRUE → return {Granted: true, CaveatState: CAVEAT_TRUE} (short-circuit)
     * Else if NO tuples are TRUE but at least one is REQUIRES_CONTEXT:
       - Collect union of all MissingParams (namespaced as "caveat_name.param")
       - Return {Granted: false, CaveatState: CAVEAT_REQUIRES_CONTEXT, MissingParams: collected}
     * Else → all tuples are FALSE → continue to step 3

3. Evaluate SubjectsComputationExpression (from Post 4):
   - Expression can be: DIRECT, COMPUTED_SUBJECTS, UNION, INTERSECTION, EXCLUSION, EDGE
   - For each operation, recursively call Check(subject, computed_resource#computed_relation, context)
   - Each recursive call returns a CheckResult with tri-state CaveatState

4. Combine results using Kleene tri-state logic:
   - For UNION: Return TRUE if ANY child is TRUE (short-circuit on TRUE)
   - For INTERSECTION: Return FALSE if ANY child is FALSE (short-circuit on FALSE)
   - For EXCLUSION: Evaluate left, then right, apply exclusion logic
   - Collect all MissingParams from REQUIRES_CONTEXT results

5. Return final CheckResult

---

OPERATION EVALUATION (Extended from Post 4 with Tri-State Logic):

OP_DIRECT:
  - No computation; only direct tuples (already checked in step 2)
  - Return {Granted: false, CaveatState: CAVEAT_FALSE}

OP_COMPUTED_SUBJECTS(computed_relation):
  - Recursively check: Check(subject, resource#computed_relation, context)
  - Return result (tri-state)

OP_UNION(children):
  - For each child expression:
    - result = Evaluate child recursively
    - If result.CaveatState == CAVEAT_TRUE → return {Granted: true, CaveatState: CAVEAT_TRUE}
  - Collect all MissingParams from REQUIRES_CONTEXT results
  - If any child returned REQUIRES_CONTEXT → return {Granted: false, CaveatState: CAVEAT_REQUIRES_CONTEXT, MissingParams: collected}
  - Return {Granted: false, CaveatState: CAVEAT_FALSE}

OP_INTERSECTION(children):
  - For each child expression:
    - result = Evaluate child recursively
    - If result.CaveatState == CAVEAT_FALSE → return {Granted: false, CaveatState: CAVEAT_FALSE}
  - Collect all MissingParams from REQUIRES_CONTEXT results
  - If any child returned REQUIRES_CONTEXT → return {Granted: false, CaveatState: CAVEAT_REQUIRES_CONTEXT, MissingParams: collected}
  - Return {Granted: true, CaveatState: CAVEAT_TRUE}

OP_EXCLUSION(left, right):
  - leftResult = Evaluate left recursively
  - If leftResult.CaveatState == CAVEAT_FALSE → return {Granted: false, CaveatState: CAVEAT_FALSE}
  - rightResult = Evaluate right recursively
  - If rightResult.CaveatState == CAVEAT_TRUE → return {Granted: false, CaveatState: CAVEAT_FALSE}
  - Collect MissingParams from both if either is REQUIRES_CONTEXT
  - If either returned REQUIRES_CONTEXT → return {Granted: false, CaveatState: CAVEAT_REQUIRES_CONTEXT, MissingParams: collected}
  - Return {Granted: true, CaveatState: CAVEAT_TRUE}

OP_EDGE(edge_from, edge_to):
  - Query tuple store for all tuples: (resource, O'#edge_from)
  - For each O' found:
    - result = Check(subject, O'#edge_to.relation, context)
    - If result.CaveatState == CAVEAT_TRUE → return {Granted: true, CaveatState: CAVEAT_TRUE}
  - Collect all MissingParams from REQUIRES_CONTEXT results
  - If any returned REQUIRES_CONTEXT → return {Granted: false, CaveatState: CAVEAT_REQUIRES_CONTEXT, MissingParams: collected}
  - Return {Granted: false, CaveatState: CAVEAT_FALSE}

Key changes from Post 4:

  1. Added context parameter: Passed through all recursive calls

  2. Tri-state output: CheckResult with CaveatState (TRUE/FALSE/REQUIRES_CONTEXT)

  3. Caveat evaluation: At step 2c, evaluate effective caveat

  4. Kleene logic: All operations extended to handle tri-state results

  5. MissingParams tracking: Collect and return missing parameters

Backward compatibility: If no caveats are attached to tuples and no context is provided, the algorithm behaves exactly like Post 4 (returns TRUE or FALSE).

Caveat Evaluation Algorithm

ALGORITHM: EvaluateCaveat(caveat, checkContext, schema)

Input:
  - caveat: ContextualizedCaveat (CaveatName + optional pre-bound Context)
  - checkContext: CaveatContext (runtime values from check request)
  - schema: CompiledSchema (caveat definitions)

Output:
  - CaveatEvalResult (State + MissingParams)

Steps:
1. Look up caveat definition by name
   - If not found → return CAVEAT_FALSE (unknown caveat → deny)

2. Merge contexts
   - Start with caveat.Context (pre-bound values)
   - Overlay checkContext (runtime values)
   - Result: mergedContext

3. Evaluate expression tree
   - For each parameter in definition:
     - If not in mergedContext → add to missingParams
   - If missingParams not empty → return CAVEAT_REQUIRES_CONTEXT

4. Evaluate boolean expression recursively
   - Start at root BooleanExpr node
   - For BOOL_PREDICATE nodes:
     * Evaluate left and right CaveatExpr
     * Apply comparison operator
     * Return boolean result
   - For BOOL_AND nodes:
     * Evaluate all children
     * Return TRUE only if ALL children are TRUE
   - For BOOL_OR nodes:
     * Evaluate all children
     * Return TRUE if ANY child is TRUE
   - For BOOL_NOT nodes:
     * Evaluate single child
     * Return logical negation
   - Convert final boolean to CAVEAT_TRUE or CAVEAT_FALSE

5. Return result
   - If missingParams: CAVEAT_REQUIRES_CONTEXT
   - Else: CAVEAT_TRUE or CAVEAT_FALSE

Kleene Three-Valued Logic Foundation

The tri-state evaluation system is built on Kleene Strong Three-Valued Logic (K3), which provides deterministic rules for combining partial information.

Canonical Kleene K3 Truth Tables:

AND (∧):

  • T ∧ T = T, T ∧ F = F, T ∧ R = R

  • F ∧ T = F, F ∧ F = F, F ∧ R = F

  • R ∧ T = R, R ∧ F = F, R ∧ R = R

OR (∨):

  • T ∨ T = T, T ∨ F = T, T ∨ R = T

  • F ∨ T = T, F ∨ F = F, F ∨ R = R

  • R ∨ T = T, R ∨ F = R, R ∨ R = R

Table format:

AND

T

F

R

T

T

F

R

F

F

F

F

R

R

F

R

OR

T

F

R

T

T

T

T

F

T

F

R

R

T

R

R

Key properties:

  • Deterministic: Same inputs always produce same outputs

  • Composable: Results can be combined predictably

  • Partial evaluation: Missing information doesn't break the system

Why Kleene logic? When some parameters are missing (R), we can still make progress:

  • T ∧ R = R (depends on the missing value)

  • F ∧ R = F (definitely false regardless)

  • T ∨ R = T (definitely true regardless)

  • F ∨ R = R (depends on the missing value)

This enables smart partial evaluation where we only request the minimum context needed.

Caveat Result Aggregation

When multiple caveats exist (e.g., schema-level + tuple-level), results are combined using Kleene logic:

// Example: Effective caveat = business_hours AND ip_allowlist
// If business_hours = TRUE and ip_allowlist = REQUIRES_CONTEXT
// Result = TRUE AND R = R (still need IP context)

func CombineCaveats(results []CaveatEvalResult) CaveatEvalResult {
    // INTERSECTION logic: ALL must be TRUE
    for _, r := range results {
        if r.IsFalse() {
            return CaveatResultFalse() // Short-circuit on FALSE
        }
    }

    // Collect all missing parameters
    allMissing := make(map[string]bool)
    for _, r := range results {
        if r.RequiresContext() {
            for _, param := range r.MissingParams {
                allMissing[param] = true
            }
        }
    }

    if len(allMissing) > 0 {
        return CaveatResultRequires(mapToSlice(allMissing))
    }

    return CaveatResultTrue()
}

Comparison: Post 4 vs. Post 5

Aspect

Post 4 (ReBAC v2)

Post 5 (ABAC v1)

Scope

Static permissions

Context-aware permissions

Permission Model

Tuples + relations

Tuples + relations + caveats

Evaluation

Deterministic (true/false)

Tri-state (true/false/requires_context)

Context

Not used

Required for caveat evaluation

Time-based Access

Not possible

Supported via caveats

Attribute Matching

Not possible

Foundation for Post 6

Typical Use Cases

Hierarchies, inheritance

Business hours, IP allowlist, expiration

Migration Guide: Adding Caveats to Existing Systems

Step 1: Identify conditional logic in application code

// Before: Application-layer time checks
if time.Now().Hour() >= 9 && time.Now().Hour() < 17 {
    // Allow access
}

// After: Caveat-based authorization
// Move logic to schema, provide context at check time

Step 2: Define caveats in schema

// Start with simple, commonly-used conditions
schema.AddCaveat(&CaveatDefinition{
    Name: "business_hours",
    Parameters: []CaveatParameter{
        &ScalarParameter{Name: "current_hour", Type: CAV_T_INT},
    },
    Expression: /* ... */,
})

Step 3: Gradual rollout strategy

// Phase 1: Add caveats to new tuples only
// Phase 2: Migrate existing tuples with caveats
// Phase 3: Remove application-layer checks

// Use feature flags to control rollout
if enableCaveats {
    // Use caveat-based check
} else {
    // Fall back to old logic
}

Step 4: Monitor and validate

  • Compare caveat results with existing application logic

  • Log discrepancies for investigation

  • Gradually increase caveat adoption percentage

  • Remove application-layer checks once confident

Common migration patterns:

  1. Time-based access: Business hours, expiration dates, maintenance windows

  2. Location-based access: IP allowlists, geo-fencing, office networks

  3. Attribute-based access: Department matching, clearance levels, user properties

  4. Request-based access: HTTP methods, API endpoints, rate limiting

Takeaways

  1. Caveats enable context-aware permissions: Instead of storing time-based roles or checking conditions in application code, attach caveats to tuples and evaluate them at check time.

  2. Tri-state evaluation handles missing context: CAVEAT_REQUIRES_CONTEXT signals that the caller must provide more information. This is safer than guessing or defaulting to deny.

  3. Caveats are composable: A single tuple can have multiple caveats (AND-ed together). A single caveat can be reused across many tuples. This keeps authorization logic DRY and maintainable.

What We've Built

We now have ABAC v1 with Caveats:

  • Direct permissions (Post 1): OP_DIRECT

  • Role-based permissions (Post 2): SubjectSet references

  • Boolean combinations (Post 3): OP_UNION, OP_INTERSECTION, OP_EXCLUSION, OP_COMPUTED_SUBJECTS

  • Cross-object inheritance (Post 4): OP_EDGE

  • Context-aware conditions (new in Post 5): CaveatDefinition + ContextualizedCaveat

The authorization check now evaluates both structural permissions (tuples + relations) and contextual conditions (caveats), enabling time-based, attribute-based, and dynamic access control.

Limitations (What's Still Missing)

  • Wildcard subjects with namespace-based parameters: "All users can access if department = HR"

  • User-defined functions & rich expression library: Custom functions like contains(), startsWith(), regex_match() for more expressive caveats

  • Determinism guarantees: Ensuring two evaluators always agree

Up next (Post 6): Organization-Scoped Conditions We'll add wildcard subjects and namespaced parameters so caveats can match attributes between users and resources. This enables "any user can view HR docs if department = HR."

Keep Reading