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: false→ call out. The policy would rather you ask the server.failOpen: true→ skip. The policy would rather proceed than block.
Failure semantics
These are the part worth reading carefully. They follow OPA’s bundle model.Fail closed until the first bundle ever loads
Fail closed until the first bundle ever loads
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.Fail open on every disconnect after that
Fail open on every disconnect after that
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.
Staleness is four timestamps, not one boolean
Staleness is four timestamps, not one boolean
No half-applied state
No half-applied state
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 toevaluate(), which stays synchronous.
Backpressure drops, it never buffers
Backpressure drops, it never buffers
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.Reports carry the rule label, never the matched text
Reports carry the rule label, never the matched text
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.Local decisions stay distinguishable from server ones
Local decisions stay distinguishable from server ones
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 everyrefresh(), 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:
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.

