Skip to main content
By default every guardrail check is a network round-trip — including a rule that is nothing more than a regular expression. Local evaluation lets the SDK download a policy bundle and evaluate those rules in-process, calling out only for the ones that genuinely need the server.
This is opt-in and additive. If you do nothing, checks keep working exactly as they do today.

Enabling it

What can be evaluated locally

The bundle says what it does not carry. If your policies use any server-evaluated type, the bundle lists it and evaluate() returns call-out rather than answering from a partial picture. The SDK never silently reports “allowed” on the basis of half a policy. What happens to a rule the SDK cannot evaluate is a per-policy decision, taken from spec.failOpen:
  • failOpen: falsecall out. The policy would rather you ask the server.
  • failOpen: trueskip. The policy would rather proceed than block.

Failure semantics

These are the part worth reading carefully. They follow OPA’s bundle model.
Before the first successful activation, evaluate() always returns call-out. Answering “allowed” from an empty bundle is indistinguishable from a project with no guardrails at all — and wrong in the one direction that matters. OPA’s /health?bundles=true returns 500 for the same reason.
Once a good bundle has been seen, a failed refresh leaves it serving. A control-plane blip must not become a global false deny across your fleet. The error is recorded in the status without disturbing the active bundle.
“Could not reach the control plane” and “reached it and got something unusable” are different problems with different fixes, and one boolean cannot tell them apart. Export these alongside your other health metrics.
A bundle is swapped in whole or not at all. A malformed or unreadable response leaves the previous revision active, so revision N stays valid while N+1 is unavailable. Version skew across a fleet is inherent to per-process caching — each revision must be individually valid, and each is.

Local decisions are reported back

A request blocked in-process never reaches the server, so without something reporting it, the dashboard would show zero hits for a policy that is blocking continuously — the faster you adopt local evaluation, the blinder your analytics get. So every local verdict is queued and sent back in the background, and lands in the same place a server-evaluated one does. Reporting is batched and fire-and-forget. It never blocks the request path, never throws, and never adds latency to evaluate(), which stays synchronous.
The queue is bounded (100 events by default). Past that, events are dropped and counted rather than buffered — a control plane that has gone away must not turn into unbounded memory growth in your process. A failed or non-2xx send is counted as dropped too, and never requeued: retrying against a dead endpoint would grow the queue forever.
A steadily climbing reportsDropped means your reporting is losing decisions — the local enforcement is unaffected, but the dashboard is seeing less than what happened. Export it alongside the staleness timestamps above.
A local reason can quote what matched. The report deliberately does not: it carries the rule’s label, the action, the policy id and the enforcement mode, and nothing of the content. Your trace already holds the content under your existing redaction settings, and duplicating it into a second store with different handling is not something the SDK will do for you.
Every reported row is marked evaluated_locally, and carries the policy id and the policy’s enforcement mode. If local and server decisions were indistinguishable, a silently broken SDK would look exactly like a quiet policy.

Flushing before a process exits

The queue is drained on every refresh(), so a long-running service that polls on a timer gets reporting for free off the cadence it already has. A process that will not call refresh() again — a serverless invocation, a CLI, a test run, anything short-lived — must flush explicitly, or lose whatever is still queued:
Like everything else on this path, it never throws.

Endpoints

Both resolve your project from the API key rather than from a path parameter — the caller is your process, not a dashboard user.
POST /v1/policy-events answers 202, not 200. The write is fire-and-forget, so the response means “accepted for storage”, not “stored”. Claiming the latter would be a promise the SDK could not verify.

Keeping the two implementations honest

The SDK cannot import server code, so its local evaluators are a second implementation of rules the server already has — the kind of copy that drifts silently and, here, would mean the SDK allowing something the server blocks. A parity test runs identical inputs through both implementations on every build and fails if they disagree on whether a rule fires. If you are reasoning about whether a local decision matches what the server would have said: it does, or the build is red.

Next steps

Policy testing

Know what a policy would catch before enforcing it.

File reference

Every field, including failOpen.

SDK guardrails

The default, server-evaluated path.

Policy as code

Authoring and applying policies.