---
title: "Policy engine"
description: "One in-process decision point for every MainSDK interface, with Cedar by default, policies as code, layers that only tighten and staged rollout."
section: "architecture"
status: "Building for 0.2"
spec: "policy"
canonical: "https://mainsdk.dev/docs/architecture/policy-engine"
updated: "2026-10-28"
---

# Policy engine

> One in-process decision point for every MainSDK interface, with Cedar by default, policies as code, layers that only tighten and staged rollout.

Status: Building for 0.2 · Canonical: https://mainsdk.dev/docs/architecture/policy-engine · Updated: 2026-10-28

One policy layer decides everything MainSDK decides: who may do what, which data may go where, which hosts and models an app may reach. The same engine and the same files run in your app, in CI and, later, from Fleet (ADR-0004).

```mermaid
flowchart LR
  subgraph callers["Every interface"]
    a["access"]
    n["network"]
    d["db"]
    ai["ai"]
  end
  callers --> dec["policy.decide()<br/>in process, deterministic"]
  files["policies/*.cedar<br/>or Rego via OPA driver"] --> dec
  cfg["Rules in main.yaml<br/>compiled to policies"] --> dec
  dec --> out{"allow or deny"}
  dec --> log[("Decision log<br/>id, policy version, layer, mode")]
```

## The call every interface makes

```ts
policy.decide({
  subject:  { type: "user", id: "u_8f3" },
  action:   { name: "orders.update" },
  resource: { type: "order", id: "8812" },
  context:  { country: "in", org: "pinecart" },
}); // { id, decision: "deny", mode: "enforce", policyVersion, reasons: ["no matching permit"], layer: "repo" }
```

The input is shaped on the OpenID AuthZEN API: subject, action, resource, context. Rules that matter:

- **Deny by default.** With no matching permit, the decision is deny. An empty `policies/` denies everything.
- **In process.** No network round trip per decision; under 1 ms at p99 with Cedar.
- **Versioned.** The policy version is a content hash of the loaded set, and every decision is logged with it.

## Engines

| Engine | How it runs |
| --- | --- |
| Cedar | Built in and the default: a pure TypeScript evaluator with no dependency, checked against Cedar's integration-test corpus for the subset it supports. An unsupported construct fails to load with a named error |
| OPA (Rego) | A driver that evaluates a compiled bundle in process; never a remote OPA per decision. Passes the same shared decision suite as Cedar |
| OpenFGA, SpiceDB and other ReBAC engines | Plug in through the `access` interface (0.3) |

```yaml title="main.yaml"
policy:
  engine: cedar                      # cedar (built in) | opa
  placement: server                  # server (default) | edge | browser
  browser: { local: false }          # opt in: lazy-load the Cedar engine in the browser
  dir: policies/
  mode: enforce                      # default for new policies is observe
```

## Observe, warn, enforce

Each policy runs in one of three modes, so adoption never breaks production:

| Mode | What happens on a deny |
| --- | --- |
| `observe` | The request is allowed; the decision is logged as `deny` with `mode: "observe"` |
| `warn` | The request is allowed with a warning |
| `enforce` | The request is denied |

## Layers only tighten

```mermaid
flowchart LR
  org["Org<br/>main.org.yaml"] --> repo["Repo<br/>policies/"] --> ov["Market and segment overlays"] --> fleet["Fleet<br/>phase 4"]
```

A lower layer can never turn a higher layer's deny into an allow. A repo policy that would do so fails to load:

```text
MAIN-POLICY-0004 repo layer weakens org: data.export
```

The error names both files.

## Explain, test, roll out

- **Explain.** `policy.explain(decisionId)` returns the layer, file, line and policy id that decided, and the `main.yaml` line when the policy was compiled from config, for example from `network.allow`.
- **Test.** `mainsdk test policy` runs your policy unit tests and replays recorded decisions, listing each one a change would flip from allow to deny or back.
- **Roll out.** A change moves through stages and rolls back on its own:

```ts
policy.rollout("egress-v4", {
  stages: ["shadow", "5%", "25%", "100%"],
  rollbackIf: { denials: "+20%", errors: "+1%" },
});
```

Canary traffic is assigned by a stable hash of the subject, so a user stays in one bucket. Every stage change is audited. Still open: how rollout state is shared across many instances, since with no Cloud in the request path each instance counts its own denials and errors.

## Placement

| `policy.placement` | Where decisions run |
| --- | --- |
| `server` (default) | Your server |
| `edge` | The edge entry `mainsdk sync` writes for your target |
| `browser` | Asks your server, or, with `policy.browser.local: true`, runs the TypeScript Cedar engine loaded lazily as a separate chunk outside the 150 KB budget |

The browser is never the only place a deny is enforced for data or actions the server owns.

## Fail static

If a new policy set fails to parse, validate or verify, the engine keeps the last good set and logs `MAIN-POLICY-0009`. It never falls back to no policy. Packs we deliver arrive signed through `mainsdk sync` (TUF from 0.4).

## Later

| What | Phase |
| --- | --- |
| Policy playground in the browser and `mainsdk policy play`, on the same TypeScript engine, nothing uploaded | 2 |
| Fleet-pushed policies signed only with the tenant's own keys | 4 (Fleet) |

## Next

- [Policy as code](https://mainsdk.dev/docs/concepts/policy): modes, layers and writing your first policy.
- [Request lifecycle](https://mainsdk.dev/docs/architecture/request-lifecycle): where the decision sits on a call.
- [Security architecture](https://mainsdk.dev/docs/architecture/security-model): the threats the engine answers.
