---
title: "Policy as code"
description: "One policy engine decides who can do what and which data may go where, in the app, in CI and later from Fleet. Cedar by default, OPA as a driver."
section: "concepts"
status: "Building for 0.2"
spec: "policy"
canonical: "https://mainsdk.dev/docs/concepts/policy"
updated: "2026-10-28"
---

# Policy as code

> One policy engine decides who can do what and which data may go where, in the app, in CI and later from Fleet. Cedar by default, OPA as a driver.

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

One policy layer runs everything MainSDK decides: who can do what, which data may go where, which hosts and models an app may reach. It is the same engine and the same files in your app at runtime and in CI as gates. Nothing is decided twice in two languages.

## Policy as code

Policies live in `policies/` next to `main.yaml`, in Cedar by default or in Rego through the OPA driver. You review them in pull requests and version them in git. The policy version is a content hash of the loaded set, so every decision names exactly what decided it.

```text title="policies/orders.cedar"
permit (
  principal,
  action == Action::"orders.update",
  resource
) when { principal.role == "editor" };

permit (
  principal,
  action == Action::"orders.read",
  resource
) when { resource.owner == principal };
```

```yaml title="main.yaml"
policy:
  engine: cedar                      # cedar (built in) | opa
  placement: server                  # server (default) | edge | browser
  dir: policies/
  mode: enforce                      # new policies default to observe
```

With no matching permit, the decision is **deny**.

```ts title="app/api/orders/[id]/route.ts"
import { auth, db, policy } from "mainsdk";

export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const session = await auth.require(req);
  const decision = policy.decide({
    subject: { type: "User", id: session.user.id, role: session.user.role },
    action: { name: "orders.update" },
    resource: { type: "Order", id },
  });
  if (decision.decision === "deny") {
    return Response.json({ reasons: decision.reasons }, { status: 403 });
  }
  const order = await db.orders.for(session.user).update(id, await req.json());
  return Response.json(order);
}
```

Most of the time you don't call `policy.decide` yourself: every interface calls it for you. For roles and permissions in app code, use [access](https://mainsdk.dev/docs/interfaces/access); full calls are on the [policy interface](https://mainsdk.dev/docs/interfaces/policy) page.

## Code as policy

The rules you write in `main.yaml` and the guarantees inside every interface compile to the same policies. `network.allow` is a policy. So is the owner scope on `db`. `mainsdk explain` shows, for any decision, the policy, the file and line, and the `main.yaml` line it was compiled from.

```text
$ npx mainsdk explain dec_01JC9A…
decision  deny   mode enforce   policy 9f2c1e…   layer repo
reason    no matching permit
source    main.yaml:21  network.allow
```

## Tested like code

```text
$ npx mainsdk test policy --recorded .mainsdk/decisions
policies/orders.cedar   12 tests passed
recorded decisions      500 replayed · 3 flip allow → deny
  orders.update  User u_8f3 (viewer)  Order 8812   allow → deny
  …
```

`mainsdk test policy` runs your policy unit tests and replays recorded real decisions, so you see what a change would allow or deny before it ships. In code, `policy.test("policies/")` does the same; for writing the tests see [Test your app](https://mainsdk.dev/docs/guides/testing).

## Observe, warn, enforce, then roll out

Every policy, and every runtime rule compiled from `main.yaml` such as `network.allow`, runs in one of three modes. New policies default to `observe`, so adopting a rule never breaks production.

| Mode | What happens |
| --- | --- |
| `observe` | The decision is logged as a would-be deny. Nothing is blocked. |
| `warn` | Allowed, with a warning in the check output and the log. |
| `enforce` | Blocked. |

```yaml title="main.yaml"
network:
  allow: ["*.pinecart.dev"]
  default: deny
  mode: observe        # observe, then warn, then enforce
```

For bigger changes, roll out in stages with automatic rollback:

```ts
import { policy } from "mainsdk";

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: with no Cloud in the request path, each instance counts its own denials and errors. Whether each instance rolls back on its own, or the stage lives in git and changes only through `sync`, is not decided.

## Layers only tighten

```mermaid
flowchart LR
  O[Org<br/>main.org.yaml] --> R[Repo<br/>policies/] --> V[Market and segment overlays] --> F[Fleet]
```

A lower layer can't turn a higher layer's deny into an allow. A repo policy that tries fails to load with both files named: `MAIN-POLICY-0004 repo layer weakens org: data.export`.

## One decision log

Every decision, in the app or in CI, goes to the audit log with the decision ID, policy version, layer and mode, in OCSF so a SIEM reads it as is.

## 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. Why that matters is on [Never in the request path](https://mainsdk.dev/docs/concepts/fail-static).

## Where decisions run

| `placement` | Where |
| --- | --- |
| `server` (default) | On your server |
| `edge` | In your edge runtime; `mainsdk sync` writes the edge entry |
| `browser` | The browser asks your server, or, with `policy.browser.local: true`, loads the TypeScript Cedar engine lazily as a separate chunk, outside the 150 KB browser budget |

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

## Bring your own engine

Existing OPA bundles run in process as compiled Rego, never as a remote call per decision, and pass the same decision tests as Cedar. Relationship engines such as OpenFGA and SpiceDB plug in through [access](https://mainsdk.dev/docs/interfaces/access).

> **Note:** The policy playground (`mainsdk policy play` and a web page that runs the same TypeScript engine) arrives in phase 2. Fleet-pushed, tenant-signed policies arrive in phase 4.

## Next

- [Permissions](https://mainsdk.dev/docs/interfaces/access)
- [Policy decisions](https://mainsdk.dev/docs/interfaces/policy)
- [Policy engine](https://mainsdk.dev/docs/architecture/policy-engine)
