MainSDK
Search⌘K
Architecture

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.

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

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")]
Diagram

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

EngineHow it runs
CedarBuilt 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 enginesPlug in through the access interface (0.3)
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:

ModeWhat happens on a deny
observeThe request is allowed; the decision is logged as deny with mode: "observe"
warnThe request is allowed with a warning
enforceThe request is denied

Layers only tighten

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

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.placementWhere decisions run
server (default)Your server
edgeThe edge entry mainsdk sync writes for your target
browserAsks 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

WhatPhase
Policy playground in the browser and mainsdk policy play, on the same TypeScript engine, nothing uploaded2
Fleet-pushed policies signed only with the tenant's own keys4 (Fleet)

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet