MainSDK
Search⌘K
Docs

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.

Building for 0.2

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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.

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 };
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.

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; full calls are on the policy interface 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.

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.

ModeWhat happens
observeThe decision is logged as a would-be deny. Nothing is blocked.
warnAllowed, with a warning in the check output and the log.
enforceBlocked.
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

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

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.

Where decisions run

placementWhere
server (default)On your server
edgeIn your edge runtime; mainsdk sync writes the edge entry
browserThe 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.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet