Enforcement modes
The five ways to implement a policy in Fabric — code (with or without DB), declarative data, hybrid, and OPA-backed — with worked examples, mixing rules, and migration paths.
Fabric supports five distinct enforcement modes for policies, and they mix freely per policy. OPA is one option among them, not a requirement. This page is the practitioner's reference: when to reach for each mode, what the code looks like, and how to combine them.
For the underlying contract see policy model; for runtime dispatch see enforcement.
The five modes at a glance
| Mode | Where the logic lives | When you'd reach for it | External infra? |
|---|---|---|---|
| Code (TS, no DB) | A PolicyEvaluator reading only ctx.parameters | Stateless validation: amount caps, enum checks, format checks | None |
| Code (TS + DB) | A PolicyEvaluator using ctx.db for cross-entity reads | "Has the user given consent?", "Is the program active?" | None |
| Data policy | A declarative DataPolicyDefinition JSON tree | Bounded comparisons compliance/admins should edit without code deploys | None |
| Hybrid | Data tree primary + code fallback | Common path is simple and editable; edges need code | None |
| OPA-backed code | A PolicyEvaluator from createOpaPolicyEvaluator(...) | Centralized declarative rules, shared across verticals, owned by compliance/security in Rego | OPA service |
Plus a future-facing slot: other-engine code (Cedar, Cerbos via similar adapter packages) — same shape as OPA from the platform's perspective. See bring your own policy engine.
Three rules of the road before the scenarios:
- The choice is per-policy, made at
registerPolicy(). Actions reference policies by ID only —policies: ["lending.rate_sheet_active.v1"]— and don't know how the policy is implemented. - You can run Fabric with zero OPA. Modes 1–4 require nothing external. Many production systems will never adopt OPA.
- You can mix freely. One action can attach four policies, each backed by a different mode. See Scenario 6 below.
Scenario 1 — Pure TS code policy, no DB
"Refund amount must be ≤ original charge."
The simplest case: a PolicyEvaluator that reads ctx.parameters and returns a verdict. No database, no external service.
import { registerPolicy, type PolicyEvaluator } from "@fabricorg/platform/policies";
export const refundCapEvaluator: PolicyEvaluator = {
policyId: "billing.refund_within_cap.v1",
version: 1,
previewSafe: true, // pure function — safe to run in preview mode
evaluate: async (ctx) => {
const params = ctx.parameters as { refundAmount: number; originalCharge: number };
if (params.refundAmount > params.originalCharge) {
return {
policyId: "billing.refund_within_cap.v1",
policyVersion: 1,
result: "block",
reason: `Refund ${params.refundAmount} exceeds original charge ${params.originalCharge}`,
};
}
return { policyId: "billing.refund_within_cap.v1", policyVersion: 1, result: "pass" };
},
};
registerPolicy(refundCapEvaluator);using FabricOrg.Platform.Policies;
public record RefundParams(double RefundAmount, double OriginalCharge);
public class RefundCapEvaluator : IPolicyEvaluator
{
public PolicyId PolicyId => "billing.refund_within_cap.v1";
public int Version => 1;
public bool PreviewSafe => true; // pure function — safe to run in preview mode
public async Task<PolicyOutcome> EvaluateAsync(IPolicyContext ctx)
{
var p = (RefundParams)ctx.Parameters;
if (p.RefundAmount > p.OriginalCharge)
{
return new PolicyOutcome
{
PolicyId = "billing.refund_within_cap.v1",
PolicyVersion = 1,
Result = PolicyResult.Block,
Reason = $"Refund {p.RefundAmount} exceeds original charge {p.OriginalCharge}",
};
}
return new PolicyOutcome
{
PolicyId = "billing.refund_within_cap.v1",
PolicyVersion = 1,
Result = PolicyResult.Pass,
};
}
}
PolicyRegistry.Register(new RefundCapEvaluator());When to use: stateless validation, no DB, no shared state. Fastest to write, fastest to evaluate, easiest to test. Mark previewSafe: true so preview-mode callers get a real answer.
Scenario 2 — TS code policy with DB reads
"Lender program must be currently active."
The most common pattern for real-world business policies. The evaluator uses ctx.db (a live transaction client typed by the host's database adapter) to read whatever domain state the rule depends on.
import { registerPolicy, type PolicyEvaluator } from "@fabricorg/platform/policies";
import type { Prisma } from "@repo/database";
export const rateSheetActiveEvaluator: PolicyEvaluator<Prisma.TransactionClient> = {
policyId: "lending.rate_sheet_active.v1",
version: 1,
previewSafe: true,
evaluate: async (ctx) => {
const lenderProgramId = (ctx.parameters as { lenderProgramId?: string }).lenderProgramId;
if (!lenderProgramId) {
return {
policyId: "lending.rate_sheet_active.v1",
policyVersion: 1,
result: "block",
reason: "Lender program ID is required",
};
}
const program = await ctx.db.lenderProgram.findFirst({
where: { id: lenderProgramId, tenantId: ctx.tenantId },
});
if (!program || program.status !== "active") {
return {
policyId: "lending.rate_sheet_active.v1",
policyVersion: 1,
result: "block",
reason: "Lender program is not active",
};
}
return { policyId: "lending.rate_sheet_active.v1", policyVersion: 1, result: "pass" };
},
};
registerPolicy(rateSheetActiveEvaluator);using FabricOrg.Platform.Policies;
public record RateSheetParams(string? LenderProgramId);
public class RateSheetActiveEvaluator : IPolicyEvaluator
{
public PolicyId PolicyId => "lending.rate_sheet_active.v1";
public int Version => 1;
public bool PreviewSafe => true;
public async Task<PolicyOutcome> EvaluateAsync(IPolicyContext ctx)
{
var p = (RateSheetParams)ctx.Parameters;
if (string.IsNullOrEmpty(p.LenderProgramId))
{
return new PolicyOutcome
{
PolicyId = "lending.rate_sheet_active.v1",
PolicyVersion = 1,
Result = PolicyResult.Block,
Reason = "Lender program ID is required",
};
}
var program = await ctx.Db.LenderProgram.FindFirstAsync(
l => l.Id == p.LenderProgramId && l.TenantId == ctx.TenantId);
if (program is null || program.Status != "active")
{
return new PolicyOutcome
{
PolicyId = "lending.rate_sheet_active.v1",
PolicyVersion = 1,
Result = PolicyResult.Block,
Reason = "Lender program is not active",
};
}
return new PolicyOutcome
{
PolicyId = "lending.rate_sheet_active.v1",
PolicyVersion = 1,
Result = PolicyResult.Pass,
};
}
}
PolicyRegistry.Register(new RateSheetActiveEvaluator());Key property: ctx.db is the live transaction client. The evaluator sees exactly what the handler will see when it runs — no replication lag, no read-your-write inconsistencies.
When to use: the policy needs to consult domain state (consents, prior decisions, configuration, related entities). This is the workhorse mode.
Scenario 3 — Declarative data policy
"Block trades > $100k for retail accounts. Compliance team adjusts the threshold quarterly without filing a PR."
Stored as JSON in your DB and registered with kind: "data". Compliance edits the JSON through an admin UI; engineering does not deploy code.
import {
evaluatePolicyDefinitions,
type RuntimePolicyDefinition,
} from "@fabricorg/platform/policies";
const tradeCapPolicy: RuntimePolicyDefinition = {
policyId: "trading.retail_trade_cap.v1",
policyVersion: 1,
kind: "data",
dataDefinition: {
conditions: [
{
conditionId: "amount_under_cap",
type: "parameter",
path: "amount",
operator: "lte",
value: 100_000,
onFail: "block",
reason: "Trade exceeds retail cap of $100,000",
},
],
},
};using FabricOrg.Platform.Policies;
var tradeCapPolicy = new RuntimePolicyDefinition
{
PolicyId = "trading.retail_trade_cap.v1",
PolicyVersion = 1,
Kind = "data",
DataDefinition = new DataPolicyDefinition
{
Conditions = new List<PolicyCondition>
{
new PolicyCondition
{
ConditionId = "amount_under_cap",
Type = "parameter",
Path = "amount",
Operator = "lte",
Value = 100_000,
OnFail = "block",
Reason = "Trade exceeds retail cap of $100,000",
},
},
},
};The platform validates the structure on every evaluation:
- max depth 5
- max 20 conditions per node, 100 total
- allowlisted operators (
exists,equals,notEquals,gt,gte,lt,lte) - path access restricted to
parameters.*and a small set of context fields - prototype-pollution paths (
__proto__,constructor,prototype) rejected
These limits are intentional. A data policy that wants to escape them is a code policy in disguise — write it as Scenario 2 instead, or use hybrid (next).
When to use: the rule is genuinely a bounded comparison and compliance wants to edit it.
Scenario 4 — Hybrid: data primary, code fallback
"Most jurisdictions follow the standard contact-window rule. A few have legal carveouts that need a domain lookup."
const tcpaHybridPolicy: RuntimePolicyDefinition = {
policyId: "lending.tcpa_contact_window.v1",
policyVersion: 1,
kind: "hybrid",
dataDefinition: {
conditions: [
{
conditionId: "within_window",
type: "all",
conditions: [
{
conditionId: "hour_ok",
type: "parameter",
path: "callHour",
operator: "gte",
value: 8,
},
{
conditionId: "hour_max",
type: "parameter",
path: "callHour",
operator: "lte",
value: 21,
},
],
},
],
defaultResult: "pass",
},
fallback: {
codeEvaluatorPolicyId: "lending.tcpa_jurisdiction_carveout.v1",
triggers: ["data_result"], // fallback fires only when data says block/warn
},
};using FabricOrg.Platform.Policies;
var tcpaHybridPolicy = new RuntimePolicyDefinition
{
PolicyId = "lending.tcpa_contact_window.v1",
PolicyVersion = 1,
Kind = "hybrid",
DataDefinition = new DataPolicyDefinition
{
Conditions = new List<PolicyCondition>
{
new PolicyCondition
{
ConditionId = "within_window",
Type = "all",
Conditions = new List<PolicyCondition>
{
new PolicyCondition
{
ConditionId = "hour_ok",
Type = "parameter",
Path = "callHour",
Operator = "gte",
Value = 8,
},
new PolicyCondition
{
ConditionId = "hour_max",
Type = "parameter",
Path = "callHour",
Operator = "lte",
Value = 21,
},
},
},
},
DefaultResult = "pass",
},
Fallback = new PolicyFallbackDefinition
{
CodeEvaluatorPolicyId = "lending.tcpa_jurisdiction_carveout.v1",
Triggers = new List<string> { "data_result" }, // fallback fires only when data says block/warn
},
};The platform evaluates the data tree first. If it returns block or warn, the dispatcher calls the registered code evaluator (a separately-registered PolicyEvaluator with id lending.tcpa_jurisdiction_carveout.v1) for a second opinion. The code evaluator can read ctx.db for jurisdiction-specific rules and override the data result either way.
dispatchEvidence.dispatchPath becomes ["data", "fallback", "code"] when the fallback fires — auditors see exactly which branch decided.
When to use: the 90% case is declarative and stable; the 10% needs domain knowledge or DB access.
Scenario 5 — OPA-backed code policy
"TCPA contact-window rules are owned by the compliance team and shared across lending + collections + servicing. Compliance wants to edit them in Rego, ship via a bundle, and see decision logs in the SIEM."
This is the BYO-engine path — see bring your own policy engine for the full design rationale. From the platform's perspective an OPA-backed evaluator is still a code policy; the OPA-specific glue lives in @fabricorg/policy-opa.
Per-policy wiring
import { createOpaPolicyEvaluator } from "@fabricorg/policy-opa";
import { registerPolicy } from "@fabricorg/platform/policies";
import { buildRateSheetActivePolicyInput } from "./build-input";
export const rateSheetActiveEvaluator = createOpaPolicyEvaluator({
binding: {
policyId: "lending.rate_sheet_active.v1",
version: 1,
decisionPath: "fabric/lending/rate_sheet_active/decision",
regoPackage: "fabric.lending.rate_sheet_active",
bundleName: "autorefi",
},
buildInput: buildRateSheetActivePolicyInput,
});
registerPolicy(rateSheetActiveEvaluator);using FabricOrg.Platform.Policies;
using FabricOrg.Platform.Policies.Opa;
var rateSheetActiveEvaluator = OpaPolicyEvaluator.Create(new OpaPolicyEvaluatorOptions
{
Binding = new OpaPolicyBinding
{
PolicyId = "lending.rate_sheet_active.v1",
Version = 1,
DecisionPath = "fabric/lending/rate_sheet_active/decision",
RegoPackage = "fabric.lending.rate_sheet_active",
BundleName = "autorefi",
},
BuildInput = BuildRateSheetActivePolicyInput,
});
PolicyRegistry.Register(rateSheetActiveEvaluator);The vertical owns three of the four pieces (manifest entry, TS fact builder, Rego itself); the adapter owns the OPA call, decision validation, evidence shape.
Host wiring (once per process)
// apps/worker/src/services/policy-runtime.ts
import { executorFromOpaHttp } from "@fabricorg/policy-opa";
export const policyRuntimeServices = {
opa: executorFromOpaHttp({
baseUrl: process.env.OPA_URL ?? "http://localhost:8181",
// provenance: true is the default — captures decision_id and bundle revision
}),
};// apps/worker/src/services/policy-runtime.cs
using FabricOrg.Platform.Policies.Opa;
public static class PolicyRuntimeServices
{
public static readonly FabricRuntimeServices Instance = new()
{
Opa = OpaExecutor.FromHttp(new OpaHttpOptions
{
BaseUrl = Environment.GetEnvironmentVariable("OPA_URL") ?? "http://localhost:8181",
// Provenance = true is the default — captures decision_id and bundle revision
}),
};
}…and the worker injects services: policyRuntimeServices when building PolicyContext.
Operational cost: you now run an OPA process (sidecar or remote service), maintain a Rego repo, and either polling-update bundles or hot-reload them. Not free. What you get:
- Centralized rules across verticals
- Compliance owns Rego, not engineering
- Bundle versioning + decision-log pipeline (with
decisionIdjoining Fabric'sPolicyEvaluationrows to OPA's external decision logs) - Independent deployment cadence — change a rule without re-deploying the app
When to use: the rule is shared across verticals, owned by a non-engineering team, or you already operate OPA for other reasons. Don't reach for OPA for a single vertical's single policy — Scenarios 2 or 3 are usually a better fit.
Scenario 6 — Mixing modes within a single action
"Order submission needs four checks, each with a different home."
Actions don't know how their policies are implemented. The platform dispatches each policy ID through whichever evaluator is registered, independently:
const submitLoanAction: ActionDefinition = {
actionId: "lending.submit_loan",
// ...
policies: [
"lending.submission_params_valid.v1", // ← data policy (declarative)
"lending.borrower_kyc_verified.v1", // ← TS code + DB
"lending.rate_sheet_active.v1", // ← OPA-backed
"lending.tcpa_contact_window.v1", // ← hybrid (data + code fallback)
],
// ...
};var submitLoanAction = new ActionDefinition
{
ActionId = "lending.submit_loan",
// ...
Policies = new List<string>
{
"lending.submission_params_valid.v1", // ← data policy (declarative)
"lending.borrower_kyc_verified.v1", // ← TS code + DB
"lending.rate_sheet_active.v1", // ← OPA-backed
"lending.tcpa_contact_window.v1", // ← hybrid (data + code fallback)
},
// ...
};At evaluation time:
Each outcome carries its own dispatchEvidence — including engine.metadata.{decisionPath, bundleRevision, decisionId} for the OPA one — and all are persisted as separate PolicyEvaluation rows tied to the same ActionInvocation. The audit trail records, per policy, how it was decided.
How to choose, per policy
┌─ Does compliance or admin need to edit it without code review / deploy?
│
│ ├─ Yes → DATA POLICY (if rule fits the bounded DSL)
│ │
│ ├─ Yes, but rule is too complex → HYBRID (data primary + code fallback)
│ │
│ └─ Yes, and shared across multiple services → OPA (if you already run OPA)
│
└─ No, engineering owns the rule:
│
├─ Needs DB access or complex relational logic → TS CODE POLICY (Scenario 2)
│
└─ Pure parameter check → TS CODE POLICY (Scenario 1)
or DATA POLICY for inspectabilityCross-cutting questions:
- Host runs on Cloudflare Workers? OPA-as-sidecar isn't available. Stick to modes 1–4.
- Compliance needs decision logs separate from app logs? OPA gives that natively (
decisionId+ decision log pipeline). TS code policies don't — you'd build it. - Rule is shared across multiple verticals or products? OPA is the natural home. Otherwise per-vertical TS or data policies are simpler.
- Policy needs to be preview-safe? TS code can be (mark
previewSafe: trueif deterministic + cheap). Data policies are always preview-safe. OPA defaults topreviewSafe: falsebecause evaluation is a network call. See policy model → preview mode.
Migration paths are reversible
Because actions reference policies by ID, and platform identity (policyId.vN) is canonical, you can change how a policy is implemented without touching the action.
A lending.rate_sheet_active.v1 policy that was a TS code evaluator yesterday can be OPA-backed today and switched back to TS tomorrow with one registerPolicy() call. No action definition changes. No consumer changes. The PolicyEvaluation audit rows differ slightly between modes (engine.metadata only appears for OPA), but policyId / policyVersion queries keep working across cutovers.
That's the design intent: engine choice is a per-policy implementation detail, not a platform-wide architecture commitment. Start simple (TS code), escalate to data/hybrid/OPA only when the specific policy justifies the operational cost, and roll back without ceremony if it doesn't.
See also
- Policy model — the
PolicyEvaluatorcontract andPolicyContext. - Enforcement — runtime dispatch mechanism for all five modes (smoke-tests an anchor-preserving rewrite).
- Bring your own policy engine — OPA worked example, adapter design, decision provenance.
- Audit trail — how dispatch evidence becomes regulator-readable.
- Policy checkpoints — cross-section relative link.