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
Everyevaluate() 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.
| Field | Type | Description |
|---|---|---|
allowed | boolean | Whether the request is permitted. |
effect | "permit" | "deny" | "indeterminate" | The combining result. indeterminate means no permission matched or an error occurred; callers should treat it as deny. |
reason | string | A human-readable or error-code string explaining the decision. On clean permits, this is "matched". |
matchedPermissionId | string | undefined | ID of the permission row that produced the final result, if available. |
matchedRelation | string | undefined | The ReBAC relation string that was consulted, if a permission has relation set. |
cacheHit | boolean | Whether this decision came from the LRU cache. |
durationMs | number | Wall time for the evaluation, rounded to the nearest millisecond. |
auditId | string | undefined | ID of the audit row written for this evaluation. Undefined if audit is disabled, sampling excluded it, or the subject is user-only. |
What it combines
Direct and delegated permissions
For agent subjects, the engine fetches permissions stored directly on the agent (via thepermissions 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 (whensubject.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 hasrelation set, the engine makes a graph query against the rebacRelationships table before counting that permission as a match.
(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:| Variable | Type | Default | Effect |
|---|---|---|---|
KAVACH_POLICY_CACHE | "true" | "false" | "true" | Disable the cache entirely. |
KAVACH_POLICY_CACHE_MAX | number | 10000 | Maximum number of cached entries. |
KAVACH_POLICY_CACHE_TTL_MS | number | 60000 | Time-to-live per entry in milliseconds. |
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:
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: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
Everyevaluate() 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
FullPolicyEngineConfig reference:
Maximum LRU entries. Evicts oldest entries when exceeded.
Time-to-live per cache entry, in milliseconds.
Set to false to disable the cache entirely. All evaluations go cold.
How to combine PERMIT and DENY results from multiple matching permissions.
Whether to write audit rows. Disabling suppresses all audit writes.
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:
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:| Path | p99 target |
|---|---|
| Cache hit | < 1 ms |
| Cold path, RBAC only | < 5 ms |
| Cold path, ReBAC walk depth 3 | < 5 ms |
| Throughput, cache warm | ≥ 50,000 evaluations/sec |
packages/core/bench/policy-engine.bench.ts and uses tinybench. Run it locally:
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.