Skip to main content
The policy engine is the single decision point for all authorization in KavachOS. One evaluate() call consults direct permissions, delegated permissions, role memberships, and ReBAC relationship tuples, then combines the results with a configurable combining algorithm. Use the policy engine when you need to make authorization decisions that depend on org membership, relationship graphs, or delegation chains. The existing authorize() function still works unchanged in v1; it shares ABAC primitives with the policy engine but evaluates only the agent’s direct permissions. Migrate calls to evaluate() when you want role expansion, ReBAC checks, or richer decision metadata.

Quick start

The PolicyDecision shape

Every evaluate() call returns a PolicyDecision. Never throw on a denied decision inside the engine: all failures, including validation errors, return allowed: false with a reason code.

What it combines

Direct and delegated permissions

For agent subjects, the engine fetches permissions stored directly on the agent (via the permissions table) and permissions received through active, non-expired delegation chains. These are the same records that authorize() evaluated before the policy engine existed.

Role-derived permissions

For user subjects (when subject.userId is present), the engine joins orgMembers to orgRoles and folds the resulting permissions into the effective set. Supply subject.orgId to scope the lookup to a specific org; without it the engine considers all orgs the user belongs to.

ReBAC relationships

When a permission has relation set, the engine makes a graph query against the rebacRelationships table before counting that permission as a match.
At evaluation time the engine calls into the existing ReBAC module to check whether (subject, "viewer", resource) is satisfied, including inherited relations from parent objects. If no tuple matches, the permission is skipped. It does not contribute a PERMIT or DENY, the next permission in the effective set is checked instead. If the graph query itself fails (database error or depth limit exceeded), the engine records POLICY_GRAPH_QUERY_FAILED and returns indeterminate rather than accidentally granting access. The engine is fail-closed on graph errors.

Caching

The engine keeps a process-local LRU cache. Defaults are 10,000 entries and a 60-second TTL. Configure via environment variables: Or pass config directly:
What gets cached: Decisions for purely RBAC and ReBAC paths are cached. A second evaluate() call with the same subject, action, resource, and IP returns in under 1ms with cacheHit: true. What does not get cached: Permissions with maxCallsPerHour or timeWindow constraints are time-sensitive. Caching those would let a rate-limited agent exceed its limit or let a timed-out permission linger past its window. The engine detects any time-sensitive permission in the effective set and skips the cache write for that evaluation entirely. Invalidation: TTL expiry happens lazily on read. For immediate invalidation after a write:
Resource-scoped invalidation flushes the entire cache rather than walking all keys to find matches, which is cheaper at high entry counts. Cross-instance invalidation: The cache is process-local. In a multi-process or multi-instance deployment, one process calling invalidate() does not affect other processes. Permissions granted or revoked will take up to ttlMs to propagate naturally via TTL expiry across all instances. See Limitations. Cache statistics are available for dashboards and benchmark assertions:

Combining algorithm

The default strategy is deny-overrides. Any explicit DENY in the matched permission set wins over any number of PERMITs. This reflects the conservative principle: if any rule says no, the answer is no. A worked example:
Even though perm-1 would allow the call in isolation, the time-window constraint on perm-2 produces a DENY, and deny-overrides means the final answer is no. If no permission matched at all, the result is indeterminate, which allowed: false represents. Callers should treat indeterminate the same as an explicit deny. To flip to permit-overrides (any PERMIT wins over DENYs), pass combineStrategy: "permit-overrides" in config. That is less common; most deployments want the conservative default.

Audit

Every evaluate() call writes one audit row to audit_logs, including cache hits. The cache_hit column distinguishes hot from cold decisions in dashboards.
auditSampleRate defaults to 1.0 (every evaluation). Dial it down in high-throughput environments where a hot cache path would otherwise generate millions of rows per hour. Current limitation: Only evaluations with an agentId in the subject write audit rows. The audit_logs table has agent_id NOT NULL, so user-only subjects (where only userId is present) are silently skipped. This will be addressed when the audit schema adds a nullable user_id column separate from agent_id. Audit writes are non-blocking. A write failure logs a warning and does not affect the returned decision. The auditId field on the decision will be undefined if the write was skipped or failed.

Configuration

Full PolicyEngineConfig reference:
number
default:"10000"
Maximum LRU entries. Evicts oldest entries when exceeded.
number
default:"60000"
Time-to-live per cache entry, in milliseconds.
boolean
default:"true"
Set to false to disable the cache entirely. All evaluations go cold.
"deny-overrides" | "permit-overrides"
default:"\"deny-overrides\""
How to combine PERMIT and DENY results from multiple matching permissions.
boolean
default:"true"
Whether to write audit rows. Disabling suppresses all audit writes.
number
default:"1.0"
Fraction of evaluations that write audit rows, from 0.0 to 1.0.

Migration from authorize()

authorize() still works. Its body now calls evaluate() internally and returns the same shape it always did. Adapters can migrate at their own pace. If you want the full PolicyDecision (cache stats, matched relation, duration), call evaluate() directly:
The authorize() signature is locked by a snapshot test in policy-backwards-compat.test.ts. Any drift from the existing return shape will fail CI. requireScopes() in mcp/require-scopes.ts still uses its own code path. It does not yet call evaluate().

Performance targets and benchmarks

Targets measured on a single M-series core: The benchmark suite lives at packages/core/bench/policy-engine.bench.ts and uses tinybench. Run it locally:
CI captures the output as a JSON artifact. A regression of more than 20% from the previous main run fails the build.

Limitations and follow-ups

These are known gaps in v1, tracked for future work: Process-local cache only. invalidate() affects only the current process. In a multi-instance deployment, stale entries persist in other processes until TTL expiry. A KV or Redis cache layer can slot in later through the same PolicyCache interface, but it is not implemented yet. No auto-invalidation on writes. The spec describes hooks that call invalidate() whenever permissions, delegation chains, org roles, org members, or ReBAC relationships are mutated. Those hooks are not yet wired to the write paths. Until they are, callers must call engine.invalidate() manually after mutations that should take effect immediately. User-only subjects skip audit. If subject.agentId is absent, no audit row is written. This is a schema constraint (audit_logs.agent_id NOT NULL), not a policy choice. No declarative policy DSL. Permissions are plain typed objects. Cedar, Rego, and Casbin-style rule languages are out of scope for v1. Permissions stay as structured data. requireScopes() is not wired. MCP scope checks in mcp/require-scopes.ts still use their own path and do not call evaluate(). The combining and caching benefits of the policy engine do not apply to scope checks yet.

Permissions

Direct permissions, constraints, and templates.

ReBAC

Relationship graph, hierarchy traversal, and tuple management.

Delegation

Grant permission subsets between agents.

Audit

How decisions are logged and surfaced in the dashboard.
Last modified on April 18, 2026