Skip to main content

What it is

PolicyEngine (packages/policy/src/PolicyEngine.ts) is the piece of Parmana that turns a policy document and a set of runtime signals into one decision: approve or reject, with a reason and a trace of which rule matched. It is deliberately narrow. Its own doc comment states what it must never do: authorize execution, execute business actions, access external systems, create trust records, replay, or generate timestamps. It is a pure function from (Policy, PolicySignals) to PolicyDecision.

Why it was built

A policy engine that could reach outside itself (call a database, hit the network, read a clock) would make every decision harder to reason about, replay, or audit. Parmana’s design keeps decisioning pure and pushes every side effect (independent verification of signals, persistence, signing) into layers around it. PolicyEngine is the load-bearing center that everything else in this chapter’s neighboring chapters (signal binding, capability binding, signal-state verification) exists to protect the inputs of.

How it works

The Policy document

A Policy (packages/policy/src/types/Policy.ts:198-300) has an identity (policyId, policyVersion, schemaVersion), an optional signalsSchema describing expected signal types, optional boundSignals and unboundSignalReasons (covered fully in Chapter 5), and an ordered array of rules. Each PolicyRule (Policy.ts:178-193) has an id, a condition, and an outcome ({ action: "approve" | "reject", reason: string }). Conditions are recursively composable (Policy.ts:169-173):
  • Leaf (PolicyLeafCondition): { fact, operator, value? }, a single comparison against one named signal.
  • all: logical AND over child conditions.
  • any: logical OR over child conditions.
  • always: unconditionally true, used as the final catch-all rule.
The full set of supported operators (PolicyValidator.OPERATORS, packages/policy/src/PolicyValidator.ts:32-71): The actual comparison logic lives in OperatorEvaluator (packages/policy/src/OperatorEvaluator.ts, imported by PolicyEngine.ts:1), PolicyEngine itself only walks the condition tree and delegates each leaf to it.

Evaluation: first-match-wins

PolicyEngine.evaluate(policy, signals) (PolicyEngine.ts:35-55) calls a private findFirstMatch, which iterates policy.rules in array order, pushing each rule’s id onto a trace array as it’s visited, and returns the first rule whose condition evaluates true against the supplied signals. This is deterministic by construction: the same policy and the same signals always produce the same matched rule, because iteration order is fixed array order, not a scored or prioritized search. A missing fact never satisfies a leaf condition (PolicyEngine.ts:93-95: if (signal === undefined) return false), an absent signal is not treated as a wildcard or a pass. If no rule matches, PolicyDecision.outcome defaults to REJECT via toOutcome’s default branch (PolicyEngine.ts:140-142) with reason: "no_rule_matched" and matchedRuleId: "none". Every real policy in this codebase ends with an explicit always: true catch-all rule specifically so this default path is never actually reached in practice, it exists as a safety net, not the intended way to reject. The returned PolicyDecision (packages/policy/src/types/PolicyDecision.ts) carries policyId, policyVersion, outcome, reason, matchedRuleId, evaluatedRules (how many rules were checked before a match), and matchedPath (the full trace of rule IDs visited) , enough for an auditor to reconstruct exactly why a decision came out the way it did, without re-running the engine.

Loading and validating: PolicyRouter

PolicyRouter (packages/policy/src/PolicyRouter.ts) is the layer above PolicyEngine that actually loads a policy by (name, version) via a PolicyRepository, then validates it before handing it back. Two validator calls happen on every load:
  1. validator.validate(policy) (PolicyRouter.ts:24), fail closed. Throws PolicyValidationError on structural problems (missing identity fields, empty rules, malformed boundSignals/unboundSignalReasons, an oversized or dangerous matches regex, see below) and, critically, on any rule-referenced fact that is neither in boundSignals nor unboundSignalReasons (findUncoveredFacts, called internally at PolicyValidator.ts:229). An uncovered fact is a hard failure, not a warning: every signal a rule can reference must be either bound to the Intent or explicitly acknowledged as independently verified.
  2. validator.findRuleConflicts(policy) (PolicyRouter.ts:30), advisory only. Detects rule pairs whose conditions can both be true, which (under first-match-wins) means the earlier rule silently shadows the later one. Logged via console.warn with event policy_rule_conflict_detected, never thrown. PolicyValidator.ts:442-466’s own doc comment explains why: unlike an uncovered fact (unambiguous fix, bind it or acknowledge it), a flagged overlap might be a real bug or the deliberately intended shape (a specific rule followed by a broader fallback). The analysis is also incomplete by design, it only reasons about pairs of single, non-nested-fact conditions; anything involving all/any is reported as NEEDS_REVIEW (level INFO) without further analysis.

The matches regex guard

Because a matches condition is evaluated against live, potentially attacker-influenced signal values, PolicyValidator.validateRegex (PolicyValidator.ts:361-391) rejects two things before a policy is ever accepted: a pattern longer than 200 characters (MAX_PATTERN_LENGTH, PolicyValidator.ts:331), and a pattern containing a quantified group whose own contents are themselves quantified (e.g. (a+)+), the textbook shape of catastrophic regex backtracking (ReDoS). The doc comment is explicit that this is a heuristic, not a proof: it catches the single-level nested-quantifier case, not every pattern capable of exponential-time backtracking. A linear-time engine or an execution timeout would be needed to close that gap completely; this is a deliberate, bounded improvement over no check at all.

Two PolicyRepository implementations

PolicyRepository (packages/policy/src/PolicyRepository.ts) is a three-method interface: load, save, listAll. Two implementations exist:
  • FilePolicyRepository, reads/writes policies/{name}/{version}/policy.json on the local filesystem. Used in local development and in tests.
  • SupabasePolicyRepository (packages/policy/src/SupabasePolicyRepository.ts), reads/writes a policies table via a direct Postgres connection. Added 2026-09-16, the same night as this handbook: Vercel’s serverless Functions run on a read-only filesystem, so the very first real production policy approval (PolicyChangeApprovalService.approve()’s live-policy write, covered fully in the policy governance chapter) failed with EROFS against FilePolicyRepository. packages/api/src/application.ts now selects between the two based on whether a real database is configured, constructing whichever one lazily rather than at module import time (see that file’s own doc comment for why eager construction was tried first and caused a different bug).
PolicyRegistry (packages/policy/src/PolicyRegistry.ts) is a separate, much smaller piece , an in-memory Map from "name:version" to registration metadata ({ name, version, path }). Its own doc comment is explicit that it does not load, evaluate, or choose policies; it only tracks what’s available.

How it enables things, with a concrete example

  • Tutorial 02 (examples/tutorials/02-policy-evaluation/run.ts) exercises PolicyEngine directly against a real policy document, showing both the approve and reject paths.
  • Tutorial 04 (examples/tutorials/04-policy-router/run.ts) exercises PolicyRouter loading a real policy from disk, including its validation step.
  • Tutorial 17 (examples/tutorials/17-multi-policy-routing/run.ts) demonstrates routing across multiple distinct policies by name/version.
  • Tutorial 116 (examples/tutorials/116-supabase-policy-repository/run.ts, 2026-09-16) demonstrates SupabasePolicyRepository directly, including the EROFS failure it fixes.

How to validate this yourself

  • Read the engine itself: packages/policy/src/PolicyEngine.ts (145 lines, worth reading in full).
  • Read packages/policy/src/types/Policy.ts for the exact shape of every field a policy document can have.
  • Run packages/policy/tests/unit/PolicyEngine.test.ts, packages/policy/tests/unit/PolicyValidator.test.ts, and packages/policy/tests/unit/PolicyRouter-boundSignals-coverage.test.ts to see the full assertion suite, including edge cases (missing facts, conflicting rules, oversized regex patterns) not covered above.
  • Pick any real policy under policies/*/*/policy.json and trace a few signal combinations through its rules by hand, the format is simple enough to do this without running code.

Integration requirements

None beyond what’s already required to run the API at all: PARMANA_POLICY_DIR (for FilePolicyRepository, local/test default ./policies) or PARMANA_STORAGE=supabase plus DATABASE_URL (for SupabasePolicyRepository in a real deployment). No policy-engine-specific configuration exists, a policy’s own content is the only per-policy configuration surface.