> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zespan.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Local policy evaluation

> Evaluate the rules that are pure functions in-process instead of over the network, with fail-closed-then-fail-open semantics borrowed from OPA's bundle model.

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.

<Note>
  This is opt-in and additive. If you do nothing, checks keep working exactly as
  they do today.
</Note>

## Enabling it

```ts theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
import { PolicyBundleStore } from "@zespan/sdk";

const policies = new PolicyBundleStore({
  baseURL: process.env.ZESPAN_API_URL!,
  apiKey: process.env.ZESPAN_API_KEY!,
  environment: "prod",
});

// Load once at startup, then refresh periodically.
await policies.refresh();
setInterval(() => void policies.refresh(), 60_000);

const decision = policies.evaluate({ text: userInput, phase: "pre", model: "gpt-4o" });

if (decision.kind === "call-out") {
  // This SDK cannot answer alone — ask the server.
  await zespan.getClient().checkGuardrails({ text: userInput, phase: "pre" });
} else if (decision.kind === "blocked") {
  throw new Error(decision.verdicts[0]!.reason);
}
```

## What can be evaluated locally

| Rule type                          | Local? | Why                                                    |
| ---------------------------------- | ------ | ------------------------------------------------------ |
| `regex`                            | Yes    | A pure function of the text                            |
| `secret_egress`                    | Yes    | The pattern set ships with the SDK                     |
| `model_governance`                 | Yes    | Decided from the model and region on the request       |
| `topic_boundary`                   | Yes    | Keyword matching                                       |
| `schema_contract`                  | No     | Would mean shipping a JSON Schema validator in the SDK |
| `custom_llm` and LLM-judge types   | No     | Needs a model call                                     |
| `cost_ceiling`, `agent_rate_limit` | No     | Needs a counter shared across processes                |
| Agent-scoped types                 | No     | Needs trace context the SDK does not hold 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.

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="Staleness is four timestamps, not one boolean">
    ```ts theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    const s = policies.getStatus();
    s.lastRequest;              // a fetch was attempted
    s.lastSuccessfulRequest;    // it returned without a transport error
    s.lastSuccessfulDownload;   // a body was read
    s.lastSuccessfulActivation; // it validated and became active
    ```

    "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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

## 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.

<AccordionGroup>
  <Accordion title="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.

    ```ts theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    const s = policies.getStatus();
    s.reportsSent;     // verdicts the server has accepted
    s.reportsDropped;  // verdicts dropped: queue full, or a failed send
    ```

    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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

### 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:

```ts theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
await policies.flushReports();
```

Like everything else on this path, it never throws.

### Endpoints

| Endpoint                 | Auth                  | Returns                                                                            |
| ------------------------ | --------------------- | ---------------------------------------------------------------------------------- |
| `GET /v1/policy-bundle`  | API key (`x-api-key`) | The compiled bundle. Send `If-None-Match` with the current revision to get a `304` |
| `POST /v1/policy-events` | API key (`x-api-key`) | **`202`**, with `{ "accepted": <count> }`                                          |

Both resolve your project from the API key rather than from a path parameter —
the caller is your process, not a dashboard user.

<Note>
  `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.
</Note>

## 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

<CardGroup cols={2}>
  <Card title="Policy testing" icon="flask" href="/policies/testing">
    Know what a policy would catch before enforcing it.
  </Card>

  <Card title="File reference" icon="list-check" href="/policies/file-reference">
    Every field, including failOpen.
  </Card>

  <Card title="SDK guardrails" icon="shield" href="/sdk/guardrails">
    The default, server-evaluated path.
  </Card>

  <Card title="Policy as code" icon="file-code" href="/policies/as-code">
    Authoring and applying policies.
  </Card>
</CardGroup>
