FabricFabricPlatform
Platform referenceGovernance

Bring your own policy engine

How to back a Fabric code policy with an external engine like OPA, Cedar, or Cerbos while keeping platform identity and audit shape canonical.

Fabric ships three policy kinds: code, data, and hybrid. The code kind is an extension point — an evaluator can implement its evaluate function any way it likes, including by delegating to an external policy engine such as Open Policy Agent, Cedar, or Cerbos.

This page describes how to wire an external engine to Fabric while preserving:

  • Canonical platform identity. PolicyId.vN and policyVersion stay load-bearing for audit.
  • Default-deny semantics. Missing or unreachable engines block, they don't pass.
  • Structured dispatch evidence. Auditors see what Fabric decided and which external artifact produced the decision.

The layering

ActionInvocation

    └── Stage 3: evaluatePolicyDefinitions

            └── code policy ──► registered PolicyEvaluator

                                   ├── (option A) inline TS logic

                                   └── (option B) buildPolicyInput(ctx) ──► external engine


                                                                       map → PolicyOutcome

From the platform's point of view, an OPA-backed evaluator is still a code policy. There is no kind: "opa". The platform stays vendor-neutral; engine-specific code lives in an adapter package and engine-specific facts live in a per-policy buildPolicyInput function.

What the platform gives you

Two affordances make BYO-engine integration first-class:

PolicyContext.services

Mirrors ActionContext.services. Lets the runtime host inject dependencies (an OPA client, a feature-flag client, an HTTP client, secret manager, etc.) into PolicyContext without module-level globals:

interface PolicyContext<TDb = unknown> {
  // ...standard fields...
  services?: FabricRuntimeServices;
}

The host builds the context, the evaluator reads from it:

const evaluator: PolicyEvaluator = {
  policyId: "lending.tcpa_contact_window.v1",
  version: 1,
  evaluate: async (ctx) => {
    const opa = ctx.services?.opa as OPAClient | undefined;
    if (!opa) {
      return {
        policyId: "lending.tcpa_contact_window.v1",
        policyVersion: 1,
        result: "block",
        reason: "OPA client not available",
      };
    }
    // ...
  },
};
public class TcpaContactWindowEvaluator : IPolicyEvaluator
{
    public PolicyId PolicyId => "lending.tcpa_contact_window.v1";
    public int Version => 1;

    public async Task<PolicyOutcome> EvaluateAsync(IPolicyContext ctx)
    {
        var opa = ctx.Services?.Opa as IOpaClient;
        if (opa is null)
        {
            return new PolicyOutcome
            {
                PolicyId = "lending.tcpa_contact_window.v1",
                PolicyVersion = 1,
                Result = PolicyResult.Block,
                Reason = "OPA client not available",
            };
        }
        // ...
    }
}

PolicyDispatchEvidence.engine

An open-ended provenance slot for the engine that produced the decision:

engine?: {
  name: string;                          // "opa" | "cedar" | "cerbos" | ...
  version?: string;                      // engine runtime version
  metadata?: Record<string, unknown>;    // engine-specific (decision path, bundle revision, ...)
};

This is the only place adapters need to write to. The platform merges engine from the evaluator's outcome into the final dispatch envelope while keeping policyId, policyVersion, and the code / data / fallback blocks under its own control.

Example evidence for an OPA-backed policy:

{
  "policyKind": "code",
  "policyId": "lending.tcpa_contact_window.v1",
  "policyVersion": 1,
  "dispatchPath": ["code"],
  "code": {
    "requestedPolicyId": "lending.tcpa_contact_window.v1",
    "policyId": "lending.tcpa_contact_window.v1",
    "version": 1,
    "registered": true
  },
  "engine": {
    "name": "opa",
    "version": "0.65.0",
    "metadata": {
      "decisionPath": "/fabric/lending/tcpa/contact_window/decision",
      "bundleRevision": "2026-05-16.3",
      "decisionId": "01J0Q...",
      "inputHash": "sha256:deadbeef"
    }
  }
}

Fabric's policyId / policyVersion answer what was evaluated. engine.metadata answers which artifact produced the answer. Both are durable on the PolicyEvaluation row.

Authoring an adapter — worked example (OPA)

A complete OPA-backed evaluator has four pieces.

1. The vertical policy manifest

Keep Fabric policy IDs, versions, engine decision paths, and package names in one vertical-owned manifest. Do not inline strings like "/fabric/lending/tcpa/contact_window" at call sites. The manifest is the contract between TypeScript registration, Rego packages, audit evidence, and deployment.

import type { OpaPolicyBinding } from "@fabricorg/policy-opa";

export const LENDING_POLICY_BINDINGS = {
  tcpaContactWindow: {
    policyId: "lending.tcpa_contact_window.v1",
    version: 1,
    decisionPath: "fabric/lending/tcpa/contact_window/decision",
    regoPackage: "fabric.lending.tcpa.contact_window",
    bundleName: "lending",
  },
} satisfies Record<string, OpaPolicyBinding>;
using FabricOrg.Platform.Policies.Opa;

public static class LendingPolicyBindings
{
    public static readonly OpaPolicyBinding TcpaContactWindow = new()
    {
        PolicyId = "lending.tcpa_contact_window.v1",
        Version = 1,
        DecisionPath = "fabric/lending/tcpa/contact_window/decision",
        RegoPackage = "fabric.lending.tcpa.contact_window",
        BundleName = "lending",
    };
}

The path belongs in the manifest because it is audit-relevant configuration, not incidental code. Adapter factories and tests should consume this object. bundleName is optional — set it when OPA serves multiple bundles and you want audit evidence to identify exactly which bundle revision produced the decision.

2. The fact builder

Per-policy TypeScript that snapshots the facts the engine needs. The runtime host (with db access) owns this; OPA never touches your database directly.

async function buildContactWindowInput(ctx: PolicyContext) {
  const consent = await ctx.db.tcpaConsent.findUnique({
    where: { partyId: ctx.parameters.partyId },
  });
  return {
    tenantId: ctx.tenantId,
    actionId: ctx.actionId,
    now: ctx.now ?? new Date(),
    consent: consent
      ? { active: consent.status === "active", scope: consent.scope }
      : null,
    contactWindow: {
      startHour: 8,
      endHour: 21,
      timezone: ctx.parameters.timezone,
    },
  };
}
public async Task<object> BuildContactWindowInputAsync(IPolicyContext ctx)
{
    var consent = await ctx.Db.TcpaConsent.FindUniqueAsync(
        new { PartyId = ctx.Parameters.PartyId });

    return new
    {
        TenantId = ctx.TenantId,
        ActionId = ctx.ActionId,
        Now = ctx.Now ?? DateTimeOffset.UtcNow,
        Consent = consent is not null
            ? new { Active = consent.Status == "active", Scope = consent.Scope }
            : null,
        ContactWindow = new
        {
            StartHour = 8,
            EndHour = 21,
            Timezone = ctx.Parameters.Timezone,
        },
    };
}

3. The Rego policy (house style)

Have your policies return a structured decision shape, not a bare boolean. That preserves the per-condition evidence granularity Fabric's data policies already give you.

# Rego — fabric/lending/tcpa/contact_window/rego
package fabric.lending.tcpa.contact_window
import rego.v1

decision := {
  "result": result,
  "reason": reason,
  "conditionResults": condition_results,
  "guidance": {
    "summary": reason,
    "factsUsed": facts_used,
    "correctiveActions": corrective_actions,
  },
}

result := "block" if not consent_active
result := "block" if not within_window
default result := "pass"

# ... rules computing reason, condition_results, facts_used, corrective_actions ...

Each corrective_actions entry must include an explicit kind:

{ "kind": "invoke_action", "label": "Record TCPA consent", "actionId": "lending.record_consent" }
{ "kind": "navigate", "label": "Open borrower profile", "route": "/leads/lead-1?tab=borrower" }
{ "kind": "manual", "label": "Verify consent with borrower" }

The Rego package should match the manifest's regoPackage, and the engine query path should be derived from the manifest's decisionPath. The adapter validates the response at runtime against the OpaDecision TypeScript type and blocks the action with engine.metadata.error = "invalid_response" on a mismatch — Rego authors get fast feedback in test environments without a separate schema build step.

4. The evaluator factory

Use the official adapter — @fabricorg/policy-opa. It wraps the OPA call, validates the decision against the house-style shape, maps it to a PolicyOutcome, and attaches engine provenance. Clients install it; they don't reimplement it.

import { createOpaPolicyEvaluator } from "@fabricorg/policy-opa";
import { registerPolicy } from "@fabricorg/platform/policies";

import { LENDING_POLICY_BINDINGS } from "../policies/manifest";

const tcpaPolicy = createOpaPolicyEvaluator({
  binding: LENDING_POLICY_BINDINGS.tcpaContactWindow,
  buildInput: buildContactWindowInput,
  serviceKey: "opa",   // default — ctx.services.opa must hold an OpaPolicyExecutor or Client
  onError: "block",    // default — honors Fabric's default-deny posture
});

registerPolicy(tcpaPolicy);
using FabricOrg.Platform.Policies;
using FabricOrg.Platform.Policies.Opa;

var tcpaPolicy = OpaPolicyEvaluator.Create(new OpaPolicyEvaluatorOptions
{
    Binding = LendingPolicyBindings.TcpaContactWindow,
    BuildInput = BuildContactWindowInputAsync,
    ServiceKey = "opa",   // default — ctx.Services.Opa must hold an IOpaPolicyExecutor or IOpaClient
    OnError = "block",    // default — honors Fabric's default-deny posture
});

PolicyRegistry.Register(tcpaPolicy);

Host wiring — ctx.services.opa

@fabricorg/policy-opa accepts two service shapes. The executor is recommended because it captures OPA-side provenance (decisionId, bundleRevision, opaVersion) that the high-level SDK discards.

import { executorFromOpaHttp } from "@fabricorg/policy-opa";

// Build once at host startup; reuse across invocations.
const opa = executorFromOpaHttp({
  baseUrl: process.env.OPA_URL ?? "http://localhost:8181",
  // provenance: true is the default — `?provenance=true` is appended so OPA
  // returns decision_id and bundle revisions in the response body.
});

// Inject when building PolicyContext for evaluatePolicyDefinitions:
const policyCtx = {
  tenantId, spaceId, actionInvocationId, actionId, parameters,
  db,
  mode: "execute",
  services: { opa },
};
using FabricOrg.Platform.Policies.Opa;

// Build once at host startup; reuse across invocations.
var opa = OpaExecutor.FromHttp(new OpaHttpOptions
{
    BaseUrl = Environment.GetEnvironmentVariable("OPA_URL") ?? "http://localhost:8181",
    // Provenance = true is the default — `?provenance=true` is appended so OPA
    // returns decision_id and bundle revisions in the response body.
});

// Inject when building PolicyContext for evaluatePolicyDefinitions:
var policyCtx = new PolicyContext
{
    TenantId = tenantId,
    SpaceId = spaceId,
    ActionInvocationId = actionInvocationId,
    ActionId = actionId,
    Parameters = parameters,
    Db = db,
    Mode = "execute",
    Services = new FabricRuntimeServices { Opa = opa },
};

The Rego package should match the manifest's regoPackage. The decision path is taken from the manifest's decisionPath everywhere — the host wiring, the audit evidence, the adapter call — so a regulator tracing a decision back to its Rego artifact never has to reconcile separate strings.

Failure behavior

External engines fail in ways in-process code does not. The adapter, not the platform, owns the failure policy. A reasonable default is:

SituationResultEvidence
Engine unreachableblockengine.metadata.error = "unreachable"
Engine returned malformed resultblockengine.metadata.error = "invalid_response"
Engine returned blockblockfull engine decision in evidence
Engine returned warnwarnfull engine decision in evidence
Engine returned passpassfull engine decision in evidence

Adapters should expose this as configurable (onError: "block" | "throw" | "fallback"), defaulting to block to honor Fabric's default-deny posture.

Preview mode

Code evaluators that perform network calls or other non-deterministic work must declare themselves not preview-safe — which is the default:

interface PolicyEvaluator<TDb = unknown> {
  policyId: PolicyId;
  version: number;
  previewSafe?: boolean;          // omit or set false for network-backed engines
  evaluate: (ctx: PolicyContext<TDb>) => Promise<PolicyOutcome>;
}

When ctx.mode === "preview", the runtime skips any code evaluator whose previewSafe is not true and returns:

  • result: "warn"
  • reason: "Policy <id> skipped in preview mode (evaluator is not declared previewSafe)"
  • dispatchEvidence.code.skipped: true
  • dispatchEvidence.code.skipReason: "..."
  • metadata.previewSkipped: true

warn is the honest answer: not proven pass, not proven block. The UI should render this as would be evaluated at execute time. Returning pass would be a lie; returning block would be needlessly pessimistic. Data policies are deterministic and continue to evaluate in preview unchanged.

What stays in the platform vs. the adapter vs. the vertical

LayerOwns
@fabricorg/platformPolicyEvaluator contract, PolicyContext (+ services), PolicyDispatchEvidence (+ engine), preview enforcement, dispatch, aggregation, persistence as PolicyEvaluation rows.
@fabricorg/policy-opa (adapter, official)createOpaPolicyEvaluator, OPA decision schema validator, default decision-to-outcome mapper, failure-mode handling (onError: "block" | "throw"), engine.name = "opa" provenance with decisionPath + inputHash. Zero runtime deps; structurally-typed OPA client.
Vertical app (e.g. lending)One OPA service/sidecar, host wires ctx.services.opa, per-policy buildPolicyInput, the Rego itself, registers the evaluators.

The platform stays zero-dependency and vendor-neutral. Hosts that can't run OPA (e.g. Cloudflare Workers) simply don't register OPA-backed policies; everything else works unchanged.

See also

  • Enforcement modes — practitioner's guide: how OPA fits among code, data, and hybrid options.
  • Policy model — the PolicyEvaluator contract.
  • Enforcement — dispatch, aggregation, default-deny.
  • Audit trail — how PolicyEvaluation rows + dispatchEvidence become regulator-readable evidence.

On this page