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

# Policy as code — manage guardrails from git

> Author guardrail policies as YAML files in your repository, review changes in pull requests, and apply them with a plan/apply loop that never silently overwrites what the dashboard owns.

Policy as code lets you keep your guardrail policies in your repository instead
of only in the dashboard. You author YAML files, review changes the same way you
review any other code, and apply them with a `plan` → `apply` loop.

The dashboard remains fully usable. A project can mix both: the platform team
keeps critical controls in git, while a compliance lead tunes others from the
UI. Neither surface can silently clobber the other.

## Why you might want this

<CardGroup cols={2}>
  <Card title="Review before enforcement" icon="code-pull-request">
    A change to what gets blocked in production goes through the same review as
    any other change, with a diff a reviewer can read.
  </Card>

  <Card title="Reproducible across environments" icon="layer-group">
    The same files apply to `staging` and `prod`, so the two cannot drift apart
    by accident.
  </Card>

  <Card title="An audit trail that proves itself" icon="fingerprint">
    Every apply records the content hash of each file, so an auditor can see
    exactly what was deployed and when.
  </Card>

  <Card title="Catch mistakes before they ship" icon="shield-check">
    `zespan policy validate` runs offline as a pre-commit hook — no network, no
    API key.
  </Card>
</CardGroup>

## The authoring loop

<Steps>
  <Step title="Scaffold a policy">
    ```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    zespan policy init
    ```

    Creates `policies/pii-egress.yaml` with a starter policy in `dryrun` mode.
  </Step>

  <Step title="Validate locally">
    ```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    zespan policy validate
    ```

    Runs entirely offline — it needs no API key and makes no network call, which
    is what makes it safe as a pre-commit hook. It reports **every** problem in
    every file at once, rather than making you fix one line and re-run.
  </Step>

  <Step title="See what would change">
    ```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    zespan policy plan --env prod
    ```

    Prints the change set without changing anything:

    ```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
      + pii-egress  (pii-egress--0)  Block PII in model output
      ~ phi-egress  (phi-egress--0)  PHI egress control

    Plan: 1 to add, 1 to change, 0 to remove.
    ```
  </Step>

  <Step title="Apply">
    ```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    zespan policy apply --env prod
    ```

    `apply` always computes a fresh plan first and sends its hash along, so you
    can never apply a plan you did not see. If the state changed in between, the
    apply is refused rather than silently applied to different state.
  </Step>
</Steps>

## Ownership: what code owns and what the dashboard owns

Every guardrail row carries an ownership marker. This is the mechanism that
makes mixing the two surfaces safe.

|                                   | Code-owned                    | Dashboard-owned     |
| --------------------------------- | ----------------------------- | ------------------- |
| Created by                        | `zespan policy apply`         | The Guardrails page |
| Editable in the dashboard         | No — read-only, with a banner | Yes                 |
| Touched by `policy apply`         | Yes                           | **Never**           |
| Shown with a `Code-managed` badge | Yes                           | No                  |

<Warning>
  `policy apply` only ever updates or removes rows it owns. A first apply from a
  directory containing one file, run against a project with twenty
  dashboard-authored guardrails, will create one row and leave the other twenty
  untouched. Pruning is bounded by ownership, never by which files happen to be
  present.
</Warning>

### Detaching a policy

Sometimes you need to change a policy from the dashboard — during an incident,
say, when editing a file and opening a PR is too slow.

Open the guardrail's detail page and choose **Detach**. Ownership moves to the
dashboard, the form becomes editable, and the next `zespan policy apply` reports
the policy as a conflict and **refuses to overwrite it**:

```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
Apply failed (409): 1 policy/policies conflict with changes made in the UI.
Those policies were detached in the UI. Re-run with --force to take ownership back in code.
```

That refusal is the point. The alternative — silently re-applying the file over
someone's emergency fix — is the failure mode this design exists to prevent.

When you are ready to move the change back into the file, edit the file to match
and run `zespan policy apply --force` to take ownership back.

## Removing a policy is a hard stop

If a policy disappears from your file set, `apply` refuses:

```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
Apply failed (409): This apply would remove 1 policy/policies: phi-egress.
Removing a policy removes a control.
Re-run with --allow-remove if that is intended.
```

Removing a guardrail removes a control, and a control that vanishes silently is
a coverage gap discovered during exactly the incident it existed to prevent.
`--allow-remove` makes it deliberate.

## The enforcement ladder

`spec.enforcement` sets the policy's posture. It applies to every rule in the
policy, so you can move a whole policy along the ladder in one line:

| `enforcement` | Effect                                                    | Use it when                                                            |
| ------------- | --------------------------------------------------------- | ---------------------------------------------------------------------- |
| `dryrun`      | Everything is recorded, nothing is surfaced or blocked    | You are introducing a policy and want to see what it would have caught |
| `warn`        | Violations are surfaced but never block or modify content | The volume looks right but you are not ready to block                  |
| `deny`        | Each rule's own `action` applies as authored              | You are enforcing                                                      |

<Tip>
  Start every new policy at `dryrun`. Look at the volume it would have caught in
  the Guardrails log, then move to `warn`, then `deny`. Going straight to `deny`
  on a rule you have never measured is how a policy takes down production
  traffic.
</Tip>

Note that `warn` also softens `redact` — a policy explicitly placed in warn mode
must be observable without changing what your application receives.

## Governing models, secrets and output shape

Three primitives exist specifically for policy files, beyond the content
guardrails you may already use:

<CardGroup cols={3}>
  <Card title="model_governance" icon="cpu">
    Allow/deny model globs, a required provider, and data-residency regions.
  </Card>

  <Card title="secret_egress" icon="key">
    Credentials and tokens in output, matched against a maintained pattern set.
  </Card>

  <Card title="schema_contract" icon="brackets-curly">
    Output validated against your own JSON Schema, not just "is it JSON".
  </Card>
</CardGroup>

```yaml theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
spec:
  rules:
    - type: model_governance
      action: block
      denyModels: ["gpt-3.5*"]
      requireProvider: anthropic
      allowRegions: ["eu-*"]

    - type: secret_egress
      action: redact

    - type: schema_contract
      action: block
      schema:
        type: object
        required: [answer, confidence]
        properties:
          answer: { type: string }
          confidence: { type: number, minimum: 0, maximum: 1 }
```

Two behaviours worth knowing, because both fail *loudly* rather than quietly:

* If `requireProvider` is set and the provider cannot be determined from the
  model name, the rule fails rather than guessing. A guess would silently admit
  a model nobody vetted.
* If `allowRegions` is set and the caller did not report a region, the rule
  fails. Passing would report a data-residency control as satisfied when it was
  never actually evaluated.

## Scoping a policy to environments

```yaml theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
spec:
  appliesTo:
    environments: [prod, staging]
```

**`appliesTo.environments` is a guard, not a router.** It does not expand one
apply into several, and it never sends a policy anywhere you did not name on the
command line. It does exactly one thing: `apply` refuses to write a policy into
an environment the policy does not list.

```text theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
Apply failed (409): 1 policy/policies do not list environment "prod" in
appliesTo.environments: staging-only.
```

The alternative reading — treat the list as a set of targets and apply to all of
them — is how a policy reaches production without anyone intending it. Naming
the environment on the command line stays the only way anything is written:

```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
zespan policy apply --env staging
```

The check compares against the **resolved** environment. `--env production`
resolves to the `prod` environment, so a policy listing `prod` applies cleanly
under either spelling; omitting `--env` resolves to the project's default
environment and is checked against that. A policy that lists no environments is
unconstrained and applies wherever you point it.

Nothing is written when the guard fires — this refusal is checked before every
other one, so you hear about a misdirected apply before you hear about a stale
plan.

## Scoping a policy to models

```yaml theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
spec:
  appliesTo:
    models: ["gpt-4*"]
```

Unlike `environments`, this one is checked on **every request**: the policy's
rules simply do not fire for a request reporting a model outside the list.
Globs use `*` only. A request that reports no model at all is out of scope
rather than blocked — passing an unvetted request through a model-scoped
control would be a worse default than skipping it, and `model_governance`
already established the convention.

## Scheduling

A policy can apply only during certain days and hours:

```yaml theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
spec:
  appliesTo:
    schedule:
      days: [mon, tue, wed, thu, fri]
      hours: "09:00-18:00"
      timezone: UTC
```

Outside the window, the compiled rules are applied in a disabled state. Only
`timezone: UTC` is honoured today; any other value falls back to UTC rather than
using the server's local offset, which would make the same policy behave
differently depending on where it ran.

## Adopting a project that already has guardrails

You do not have to hand-write a file for every guardrail you already built.
`zespan policy pull` generates them from what is already there:

<Steps>
  <Step title="Generate the files">
    ```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    zespan policy pull --env prod
    ```

    Writes one file per guardrail into `policies/`, and prints a warning for
    anything it could not express exactly (see below). It never overwrites a file
    you have already edited unless you pass `--force`.
  </Step>

  <Step title="Check that it round-trips">
    ```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    zespan policy plan --env prod
    ```

    This should report **no changes to make** — only pending adoptions. That is
    the point of generating rather than transcribing: the files and the live
    state agree by construction, so any drift you see later is real drift and
    not an artifact of adoption.
  </Step>

  <Step title="Take ownership">
    ```bash theme={"theme":{"light":"github-light","dark":"one-dark-pro"}}
    zespan policy apply --env prod --adopt
    ```

    Each guardrail becomes code-owned **in place** — it keeps its id, so its
    execution history and anything referencing it survive the handover. From
    here it is read-only in the dashboard, with the usual Detach escape hatch.
  </Step>
</Steps>

`--adopt` is required for the same reason `--allow-remove` is: taking over a
guardrail makes it read-only for whoever built it in the dashboard, and that
should be a decision rather than a side effect.

### What `pull` cannot express

Generation is not lossless, and it says so at the time rather than letting you
discover it later:

| Case                                        | What happens                                                                                        |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| `spec.failOpen`                             | A guardrail row does not record one. Written as `false`, and always warned about — review it.       |
| A non-default `maxLatencyMs`                | The file format has no field for it. Applying the file resets it to the default.                    |
| A guardrail created from a template         | The file captures its current settings, not its link to the template.                               |
| A disabled guardrail                        | The format has no disabled flag, so applying would enable it. Leave it out of the file set instead. |
| A slug with characters the format disallows | No file is generated for it. Rename it in the dashboard first.                                      |

## Reviewing changes in the dashboard

**Guardrails → Review changes** does what `zespan policy plan` does: paste or
upload policy files and see the same change set, computed by the same endpoint,
so the two cannot disagree.

It is deliberately read-only. Reviewing needs `policy:read`, which every role
has; applying needs `policy:apply`, which only owner and admin have. Somebody
reviewing a pull request should not need a terminal, and should not gain the
ability to apply by using the dashboard instead.

## Before you enforce

Promoting a policy to `deny` requires a backtest or an explicit `--untested`.
`zespan policy test` runs the policy over your own recorded traffic and shows
what it would have caught and what it would have broken — see
[Policy testing](/policies/testing).

Policies can also be evaluated **in-process** by the SDK rather than over the
network, for the rules that are pure functions — see
[Local evaluation](/policies/local-evaluation).

## What is not in this release

* **Compliance packs** — installable HIPAA / EU AI Act / PCI / OWASP-LLM policy
  sets.
* **Custom expressions and webhooks** — CEL predicates and customer-hosted
  evaluation endpoints.

## Next steps

<CardGroup cols={2}>
  <Card title="File reference" icon="list-check" href="/policies/file-reference">
    Every field, its type, and whether it is required.
  </Card>

  <Card title="zespan policy" icon="terminal" href="/cli/policy">
    The full CLI verb reference.
  </Card>

  <Card title="Guardrails" icon="shield" href="/dashboard/guardrails">
    The dashboard side, including the code-managed badge and Detach.
  </Card>

  <Card title="CLI overview" icon="gear" href="/cli/overview">
    Configuration precedence for API keys and project ids.
  </Card>
</CardGroup>
