---
title: 'ADR 0003: Policy engine'
description: A TypeScript intermediate representation evaluated in-process, with Rego as an export — and why not OPA as the engine.
label: '0003 Policy engine'
---

## Context

The gateway evaluates policy on every protected action. Four properties are
non-negotiable:

**Deterministic.** The same input must always produce the same decision. A
policy engine that can decide differently on a Tuesday is not something you put
in front of production.

**Total.** No operator may throw. An exception mid-evaluation is an outcome
nobody specified, and under fail-closed it becomes a refusal with no
explanation.

**In-process.** The gateway cannot take a network hop to another service in
order to decide whether to allow something. That service could be down, and
fail-closed would then turn every evaluation-service blip into a refusal of all
traffic. [ADR 0002](/architecture/adr/0002-gateway-architecture)

**Round-trippable with a visual builder.** Most policies are authored by people
who will not write a policy language. The stored artefact has to be something a
UI can read, render, edit and write back without loss.

The obvious candidate is Open Policy Agent with Rego. It is the standard, it is
mature, and this decision is the one most likely to be questioned.

## Decision

A **TypeScript intermediate representation**, stored as JSON in PostgreSQL,
evaluated by an in-process evaluator.

```json
{
  "schemaVersion": 1,
  "defaultDecision": "ALLOW",
  "defaultReason": "No rule matched.",
  "rules": [{
    "id": "rule-refund-ceiling",
    "name": "Approval above ₹1,000",
    "priority": 700,
    "enabled": true,
    "when": {
      "kind": "condition",
      "id": "c-amount",
      "field": "amount",
      "operator": "gt",
      "value": 100000
    },
    "then": { "decision": "REQUIRE_APPROVAL", "reason": "…" }
  }]
}
```

That JSON **is** the policy — not a compilation artefact, not a cache. It is
what the visual builder reads and writes, and what the evaluator executes.

Three constraints make the properties hold:

**A closed field set.** 58 fields, enumerated. An unknown field is a validation
error at authoring time, not an `undefined` at decision time.

**Total operators.** 20 operators, each defined for every input type. An
unresolvable comparison evaluates to `false` rather than throwing.

**Specified precedence.** Every matching rule contributes a candidate decision;
the **strictest** wins. `BLOCK` > `REQUIRE_APPROVAL` > `STEP_UP_AUTH` >
`LIMIT` > `MASK` > `ALLOW`. Priority orders evaluation and the explanation; it
does not override strictness.

**Rego is an export**, not the engine. `compileRuleSetToRego` renders an
equivalent module so a team already running OPA can enforce the same semantics
outside the Node process.

## Alternatives

**OPA as the engine, Rego as the source.** The standard choice, and it was taken
seriously.

*Why not:* evaluating Rego in-process means embedding a WASM runtime and
shipping a compiled policy bundle, which adds a build step between authoring and
enforcement — and a build step is a place a policy can be stale. Running OPA as
a sidecar reintroduces the network hop that fail-closed makes dangerous.

The round-trip problem is the harder one. Rego is a general-purpose language with
comprehensions, partial evaluation and user-defined functions. A visual builder
can emit a subset of Rego; it cannot read arbitrary Rego back. So you get either
a builder that silently refuses to open hand-written policies, or a product
where the UI and the real policy are different artefacts that drift.

And totality is not a Rego property. A Rego policy can fail to produce a
decision, and you then have to specify what that means — which is the problem
the closed field set and total operators were introduced to avoid.

**Cedar.** Closer in spirit: analysable, total, designed for authorization. The
decision model is oriented around principal-action-resource, and Veefore needs
to test risk scores, velocity counters, trust evidence, delegation depth and
destination hosts. Expressible, awkwardly. Also a build step between authoring
and enforcement.

**CEL.** Good expression language, total, embeddable. It evaluates expressions;
it does not carry rules, priorities, effects or the merge semantics. Most of the
work would still have to be built around it, and the IR would end up being the
real artefact anyway.

**A custom DSL with a parser.** More readable than JSON for someone writing by
hand. Adds a parser, a formatter, error recovery, and the same round-trip
problem as Rego. The IR is already readable enough that hand-authoring works.

**Database-stored rows rather than a JSON document.** Queryable, and it makes a
rule set a join rather than a value — so versioning and diffing become
significantly harder, and the atomic thing you activate is no longer a single
object.

## Consequences

**Policy is not portable to another engine as-is.** Mitigated by the Rego
export, which is real and tested, and which means a team can run OPA alongside
for review or for a second enforcement point. It is an export, so the semantics
are equivalent rather than identical by construction.

**Expressiveness is deliberately limited.** No comprehensions, no user-defined
functions, no recursion. Every policy is a flat list of rules over a closed
field set. Some things people might want to express are not expressible.

That limit is what buys determinism, totality, sub-millisecond evaluation, and a
visual builder that round-trips. It is the trade, stated plainly.

**Adding a field is a code change.** It goes in `POLICY_FIELDS`, gets a
descriptor with its type and permitted operators, and flows automatically into
the builder, the evaluator, the Rego export, and
[the generated reference page](/reference/policy-fields). Deliberate friction: a
new field is new authority for policy authors.

**The precedence rule surprises people.** A `BLOCK` from a priority-10 rule
beats an `ALLOW` from a priority-900 rule, and a deny-by-default policy bound
broadly denies everything in that scope. Both are correct and both generate
support questions. Documented prominently in
[Policy](/concepts/policy#precedence-the-part-to-read-twice) and in the
[FAQ](/faq).

**Totality has to be tested for.** `tests/policy` includes 31 cases asserting
every operator against every input type, and specifically that no operator
throws. A property you cannot see is a property you have to assert.

## Related

- [Policy](/concepts/policy)
- [Policy fields](/reference/policy-fields)
- [Write your first policy](/build/first-policy)
- [Gateway architecture](/architecture/adr/0002-gateway-architecture)
