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 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.
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")]The call every interface makes
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) |
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 observeObserve, 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
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:
MAIN-POLICY-0004 repo layer weakens org: data.exportThe error names both files.
Explain, test, roll out
- Explain.
policy.explain(decisionId)returns the layer, file, line and policy id that decided, and themain.yamlline when the policy was compiled from config, for example fromnetwork.allow. - Test.
mainsdk test policyruns 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:
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: modes, layers and writing your first policy.
- Request lifecycle: where the decision sits on a call.
- Security architecture: the threats the engine answers.
Was this page helpful?