---
title: "Policy decisions"
description: "Decide every sensitive call in process, explain which rule decided, and later load policies from git and roll changes out in shadow first."
section: "interfaces"
status: "Building for 0.2 · ships as a stable API"
spec: "policy"
canonical: "https://mainsdk.dev/docs/interfaces/policy"
updated: "2026-10-28"
---

# Policy decisions

> Decide every sensitive call in process, explain which rule decided, and later load policies from git and roll changes out in shadow first.

Status: Building for 0.2 · ships as a stable API · Canonical: https://mainsdk.dev/docs/interfaces/policy · Updated: 2026-10-28

`policy` is the one layer that decides who may do what and what data, hosts and models an app may reach. You ask a question and get allow or deny, the reasons and an id.

You ask it in your own code when a decision is sensitive, such as exporting data or acting across a border. Every decision runs in process and is written to the audit log.

What runs today is the built-in engine that [`access`](https://mainsdk.dev/docs/interfaces/access) also uses: roles, owner rules, named rules, relations and the three modes. `policy.decide()` and `policy.explain()` are thin calls over it, and you configure it under `access:` in `main.yaml`. Policies as code in `policies/`, the Cedar and OPA engines, layers, `policy.test()`, `policy.rollout()` and `policy.decideRemote()` are planned for 0.2 (spec `policy`) and are marked below. Do not write them yet.

## At a glance

- Status: Building for 0.2 · ships as a stable API
- Phase: 1 (Core, public beta)
- Milestone: 0.2 Core loop
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/policy.md
- Category: Identity and access

Calls:

- `policy.decide()`
- `policy.explain()`
- `policy.test()`
- `policy.rollout()`
- `policy.decideRemote(input)`

In main.yaml:

```yaml
policy:
  engine: cedar
  placement: server
  dir: policies/
  mode: observe                # the default for new policies
```

Guarantees, whatever the driver:

- Deny when no policy permits
- In process, no network round trip per decision
- Lower layers only tighten, never loosen
- Keeps the last good policy set when a new one fails to load


## Drivers

Pick one in main.yaml with `policy.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Cedar (built in | `builtin` | yes |  |
| Open Policy Agent | `opa` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-POLICY-0004` | check | A repo policy weakens an org policy; loading fails, naming both files |
| `MAIN-POLICY-0001` | runtime |  |
| `MAIN-POLICY-0002` | runtime |  |
| `MAIN-POLICY-0003` | runtime |  |
| `MAIN-POLICY-0005` | runtime |  |
| `MAIN-POLICY-0006` | runtime |  |
| `MAIN-POLICY-0009` | runtime | A new policy set failed to load; the previous version stays in force |
| `MAIN-POLICY-0011` | runtime | A Cedar policy uses a construct the built-in TypeScript engine doesn't support yet; it names the construct and line and never ignores it |
| `MAIN-POLICY-0012` | runtime |  |
| `MAIN-PLAY-0001` | runtime | The tests file is not a list of tests with a name, an input and `allow` or `deny` as the expectation (400). Fix: write each test as `{ "name": "…", "input": { … }, "expect": "allow" }` |
| `MAIN-PLAY-0002` | runtime | The replay holds more recorded decisions than the playground replays at once (413). Fix: replay a smaller sample of at most 100000 decisions |
| `MAIN-PLAY-0003` | runtime | The link does not hold a playground state this version can read (400). Fix: ask for the link again, or paste the policy and request in by hand |
| `MAIN-PLAY-0004` | runtime | The state is too large to share, or getqr.id did not make the link (502 or 413). Fix: share without the replay, or share again when the connection is back |
| `MAIN-PLAY-0005` | runtime | `mainsdk policy play` could not read a policy, request, entities, tests or replay file (400). Fix: pass a folder with policies and a request file that holds JSON |
| `MAIN-POLICY-0007` | runtime | `mainsdk test policy --recorded` was given a folder that does not exist |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Deny-by-default decisions | 1 | 0.2 | `policy#decide` |
| Policies folder with a content-hashed version | 1 | 0.2 | `policy#policies-folder` |
| Observe, warn and enforce modes | 1 | 0.2 | `policy#modes` |
| Org, repo, overlay and Fleet layers | 1 | 0.2 | `policy#layering` |
| Explain a decision | 1 | 0.2 | `policy#explain` |
| Policy tests and recorded replay | 1 | 0.2 | `policy#test` |
| Staged rollout with automatic rollback | 1 | 0.2 | `policy#rollout` |
| OPA driver for Rego | 1 | 0.2 | `policy#opa-driver` |
| Last good policy set | 1 | 0.2 | `policy#fail-static` |
| Server, edge or browser placement | 1 | 0.2 | `policy#placement` |
| Policy playground | 2 | 0.7 | `policy-playground` |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| teams | Policy shared across the organisation and rolled out to the team's projects |
| fleet | Central policy pushed to every app in the estate, signed with keys the tenant holds |


## Quick start

```yaml title="main.yaml"
access:
  mode: enforce        # enforce | warn | observe
  roles:
    admin: ["*"]
    analyst: [reports.read]
```

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

declare function startExport(reportId: string): Promise<Response>; // your own export job

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const session = await auth.require(req);

  const d = policy.decide({                       // synchronous, in process
    subject: { type: "user", id: session.user, roles: ["analyst"] },
    action: { name: "export.data" },
    resource: { type: "report", id },
    context: { purpose: "audit" },
  });
  if (!d.allow) {
    return Response.json({ reasons: d.reasons, decisionId: d.id }, { status: 403 });
  }
  return startExport(id);
}
```

With no matching rule the decision is `deny`, with the reason `no role or owner rule grants export.data`. A decision is `{ id, decision, allow, mode, policyVersion, reasons }`. In a real app the roles come from the tenant membership when you leave `roles` out.

Check `d.allow`, not `d.decision`. In `observe` and `warn` mode `decision` can be `"deny"` while `allow` is `true`, so the request goes ahead and the would-be denial is logged.

## Configuration

Today the keys live under `access:`. See [permissions](https://mainsdk.dev/docs/interfaces/access) for all of them.

```yaml title="main.yaml"
access:
  roles:
    admin: ["*"]                  # every action
    analyst: [reports.read]
  owner: [read, update]           # on a resource whose owner is the subject
  when:
    export.data: [audit-only]     # named rules that must also hold
  mode: enforce                   # enforce | warn | observe
```

| Key | What it does |
| --- | --- |
| `roles` | Role name to the actions it grants. `"*"` grants every action, `"reports.*"` every reports action |
| `owner` | Actions a subject may take on a resource whose `owner` is their id |
| `when` | Action to named rules that must also hold. A rule that is not registered fails closed |
| `mode` | `enforce` blocks, `warn` and `observe` allow and record the would-be decision. Default `enforce` |

The policy version is a short hash of this section, and every decision carries it.

Planned for 0.2, not in the code yet: a `policy:` section with `engine` (`cedar` or `opa`), `placement` (`server`, `edge` or `browser`), `dir: policies/` for policies kept in git, and layers where org, repo, market and Fleet policies apply in order and a lower layer only tightens.

## What you get

- An allow or deny for each question, with the reasons, the mode and the policy version.
- A decision id that `policy.explain()` turns back into the rule that decided.
- In-process, deterministic decisions with no network round trip, each written to the audit log.
- Deny by default when no rule permits, and a denial across tenants before any rule runs.

## Examples

### Explain a denial

Every decision has an id. `policy.explain(id)` returns the decision and the request that produced it, or `null` for an id it does not hold. It keeps the last 10,000 decisions of the process.

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

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  await auth.require(req, { role: "admin" });
  const found = policy.explain(id);
  return found ? Response.json(found) : new Response(null, { status: 404 });
}
```

The reasons name the rule: `role admin grants *`, `owner of report may read`, `no role or owner rule grants export.data`, `cross-tenant`, or `the API key does not have this scope`.

### Shadow a new rule, then enforce it

Set `access.mode: observe`. The request is allowed and the would-be decision is logged with `mode: "observe"`. Move to `warn`, then `enforce`, once the log looks right.

```ts title="server/transfers.ts"
import { auth, policy } from "mainsdk";

declare function createTransfer(body: { accountId: string; amount: number }): Promise<unknown>; // your own transfer code

export async function POST(req: Request) {
  const session = await auth.require(req);
  const body = (await req.json()) as { accountId: string; amount: number };

  const d = policy.decide({
    subject: { type: "user", id: session.user, roles: ["member"] },
    action: { name: "transfers.create" },
    resource: { type: "account", id: body.accountId, amount: body.amount },
  });
  if (!d.allow) return Response.json({ reasons: d.reasons }, { status: 403 });   // only enforce blocks
  return Response.json(await createTransfer(body));
}
```

## Planned

These are in the spec and the catalog, and none of them is in the code yet.

- **Policies as code.** Cedar files in `policies/`, a built-in TypeScript Cedar evaluator and an OPA driver for compiled Rego, with a content-hashed policy version and the last good set kept when a new one fails to load.
- **Layers.** Org, repo, market and Fleet policies, where a lower layer only tightens. A repo policy that weakens an org one fails to load with `MAIN-POLICY-0004`.
- **`policy.test()` and `mainsdk test policy`** to unit-test policies and replay recorded decisions.
- **`policy.rollout()`** to stage a change (shadow, then 5%, 25%, 100%) with automatic rollback.
- **`policy.decideRemote()`** for decisions in the browser or at the edge.

Questions the spec still lists as open: how rollout state is kept across instances, how `DecisionInput` maps to Cedar entities beyond `context`, and what counts as weakening beyond deny-to-allow.

## Errors and checks

A denied decision is a value, not an error: `policy.decide()` does not throw. Code that turns a denial into an error uses [`access.require()`](https://mainsdk.dev/docs/interfaces/access), which throws a `MainError` with status 403.

The policy-loading codes are planned with the policy files: `MAIN-POLICY-0004` (a lower layer weakens a higher one) and `MAIN-POLICY-0009` (a new set fails to load and the last good set stays in force; see [fail static](https://mainsdk.dev/docs/concepts/fail-static)).

## Swapping drivers

Today there is one engine, the built-in one. The engine choice (`cedar` or `opa`) arrives with the `policy:` section, and the `policy.decide()` and `policy.explain()` calls will stay the same.

## Testing

`createTestApp` builds the app from a `main.yaml` string, so a test states its own rules.

```ts title="lib/export.test.ts"
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { policy } from "mainsdk";

createTestApp({ yaml: "schema: 1\nproject: prj_test\naccess:\n  roles:\n    analyst: [reports.read]\n" });

const d = policy.decide({
  subject: { id: "u_1", roles: ["analyst"] },
  action: "export.data",
  resource: { type: "report", id: "r_1" },
});
assert.equal(d.allow, false);
assert.equal(policy.explain(d.id)?.decision.reasons[0], "no role or owner rule grants export.data");
```

`policyTest` and `mainsdk test policy` arrive with policy files.

## Security and privacy

- Deny when no rule permits. A named rule that is not registered denies.
- Decisions run in process and are deterministic, with no network round trip.
- Every decision is written to the audit log with the decision id, policy version and mode.
- A decision across tenants is denied before any rule runs, and an API key never widens what the rules allow.

Everything on this page runs in your app and is free; see [free and Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud) for what the plans add.

## FAQ

**Does a decision ever wait on the network?** No. The built-in engine evaluates in process. MainSDK Cloud is never in the request path.

**How is this different from `access`?** [`access`](https://mainsdk.dev/docs/interfaces/access) is the permissions interface for your app's records: `access.check()`, `access.require()`, relations and rules. `policy` is the same engine seen as the layer for every decision MainSDK makes, and it is where Cedar, layers and rollout will land.

**Can my repo loosen an org rule for one case?** Layers are planned. Exceptions with an owner and an expiry will live in `main.yaml`; see [main.yaml](https://mainsdk.dev/docs/concepts/main-yaml).

## Next

- [Permissions](https://mainsdk.dev/docs/interfaces/access): roles, rules and relations.
- [Network permissions](https://mainsdk.dev/docs/interfaces/network): rules for the hosts an app may reach.
- [Fail static](https://mainsdk.dev/docs/concepts/fail-static): why the last good policy stays in force.
- [Try a policy in the playground](https://mainsdk.dev/docs/guides/try-a-policy-in-the-playground): `mainsdk policy play` and the browser page; codes `MAIN-PLAY-0001` to `0005` are in [check codes](https://mainsdk.dev/docs/reference/check-codes).
