Policy Engine
EDC includes a policy engine for evaluating policy expressions. It’s important to understand its design center, which
takes a code-first approach. Unlike other policy engines that use a declarative language, the EDC policy engine executes
code that is contributed as extensions called policy functions. If you are familiar with compiler design and visitors,
you will quickly understand how the policy engine works. Internally, policy expressed as ODRL is deserialized into a
POJO-based object tree (similar to an AST) and walked by the policy engine.
Let’s take one of the previous policy examples:
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "PolicyDefinition",
"policy": {
"@context": "http://www.w3.org/ns/odrl.jsonld",
"@id": "8c2ff88a-74bf-41dd-9b35-9587a3b95adf",
"duty": [
{
"target": "http://example.com/asset:12345",
"action": "use",
"constraint": {
"leftOperand": "headquarter_location",
"operator": "eq",
"rightOperand": "EU"
}
}
]
}
}
When the policy constraint is reached during evaluation, the policy engine will dispatch to a function registered under
the key header_location. Policy functions implement the AtomicConstraintRuleFunction interface:
@FunctionalInterface
public interface AtomicConstraintRuleFunction<R extends Rule, C extends PolicyContext> {
/**
* Performs the evaluation.
*
* @param operator the operation
* @param rightValue the right-side expression for the constraint
* @param rule the rule associated with the constraint
* @param context the policy context
*/
boolean evaluate(Operator operator, Object rightValue, R rule, C context);
}
A function that evaluates the previous policy will look like the following snippet:
public class TestPolicy implements AtomicConstraintRuleFunction<Duty, ParticipantAgentPolicyContext> {
public static final String HEADQUARTERS = "headquarters";
@Override
public boolean evaluate(Operator operator, Object rightValue, Duty rule, ParticipantAgentPolicyContext context) {
if (!(rightValue instanceof String headquarterLocation)) {
context.reportProblem("Right-value expected to be String but was " + rightValue.getClass());
return false;
}
var participantAgent = context.participantAgent();
var claim = participantAgent.getClaims().get(HEADQUARTERS);
if (claim == null) {
return false;
}
// ... evaluate claim and if the headquarters are in the EU, return true
return true;
}
}
Note that PolicyContext has its own hierarchy, that’s tightly bound to the policy scope.
CEL Policy Expressions
The code-first approach described above is the primary way to evaluate policies, but it requires writing and
deploying a Java extension for each new constraint. As an experimental alternative, EDC can evaluate policy
constraints declaratively using Common Expression Language (CEL)
expressions that are managed as data through the Management API — no code required. This is especially handy for
credential-based rules, which ship with a set of ready-made helper functions.
See CEL Policy Expressions for the details.
Policy Scopes and Bindings
In EDC, policy rules are bound to a specific context termed a scope. EDC defines numerous scopes, such as one for
contract negotiations and provisioning of resources. To understand how scopes work, consider the following case,
“to access data, a consumer must be a business partner in good standing”:
{
"constraint": {
"leftOperand": "BusinessPartner",
"operator": "eq",
"rightOperand": "active"
}
}
In the above scenario, the provider EDC’s policy engine should verify a partner credential when a request is made to
initiate a contract negotiation. The business partner rule must be bound to the contract negotiation scope since
policy rules are only evaluated for each scope they are bound to. However, validating a business partner credential
may not be needed when data is provisioned if it has already been checked when starting a transfer process. To avoid an
unnecessary check, do not bind the business partner rule to the provision scope. This will result in the rule being
filtered and ignored during policy evaluation for that scope.
The relationship between scopes, rules, and functions is shown in the following diagram:

Rules are bound to scopes, and unbound rules are filtered when the policy engine evaluates a particular scope. Scopes
are bound to contexts, and functions are bound to rules for a particular scope/context. This means that separate
functions can be associated with the same rule in different scopes. Furthermore, both scopes and contexts are hierarchical
and denoted with a DOT notation. A rule bound to a parent context will be evaluated in child scopes.
Be careful when implementing policy functions, particularly those bound to the catalog request scope (request.catalog),
which may involve evaluating a large set of policies in the course of a synchronous request. Policy functions should be
efficient and avoid unnecessary remote communication. When a policy function makes a database call or invokes a
back-office system (e.g., for a security check), consider introducing a caching layer to improve performance if testing
indicates the function may be a bottleneck. This is less of a concern for policy scopes associated with asynchronous
requests where latency is generally not an issue.
In Force Policy
The InForce is an interoperable policy for specifying in force periods for contract agreements. An in force period can
be defined as a duration or a fixed date.
All dates must be expressed as UTC.
Duration
A duration is a period of time starting from an offset. EDC defines a simple expression language for specifying the
offset and duration in time units:
<offset> + <numeric value>ms|s|m|h|d
The following values are supported for <offset>:
| Value | Description |
|---|
| contractAgreement | The start of the contract agreement defined as the timestamp when the provider enters the AGREED state expressed in UTC epoch seconds |
The following values are supported for the time unit:
| Value | Description |
|---|
| ms | milliseconds |
| s | seconds |
| m | minutes |
| h | hours |
| d | days |
A duration is defined in a ContractDefinition using the following policy and left-hand
operands inForceDate:
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "Offer",
"@id": "a343fcbf-99fc-4ce8-8e9b-148c97605aab",
"permission": [
{
"action": "use",
"constraint": {
"and": [
{
"leftOperand": "inForceDate",
"operator": "gt",
"rightOperand": "contractAgreement"
},
{
"leftOperand": "inForceDate",
"operator": "lt",
"rightOperand": "contractAgreement + 100d"
}
]
}
}
]
}
Fixed Date
Fixed dates may also be specified as follows using inForceDate operands:
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "Offer",
"@id": "a343fcbf-99fc-4ce8-8e9b-148c97605aab",
"permission": [
{
"action": "use",
"constraint": {
"and": [
{
"leftOperand": "inForceDate",
"operator": "gt",
"rightOperand": "2023-01-01T00:00:01Z"
{
"leftOperand": "inForceDate",
"operator": "lt",
"rightOperand": "2024-01-01T00:00:01Z"
}
]
}
}
]
}
Although xsd:datatime supports specifying timezones, UTC should be used. It is an error to use an xsd:datetime
without specifying the timezone.
No Period
If no period is specified the contract agreement is interpreted as having an indefinite in force period and will remain
valid until its other constraints evaluate to false.
Not Before and Until
Not Before and Until semantics can be defined by specifying a single inForceDate
fixed date constraint and an
appropriate operand. For example, the following policy
defines a contact is not in force before January 1, 2023:
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "Offer",
"@id": "a343fcbf-99fc-4ce8-8e9b-148c97605aab",
"permission": [
{
"action": "use",
"constraint": {
"leftOperand": "edc:inForceDate",
"operator": "gt",
"rightOperand": {
"@value": "2023-01-01T00:00:01Z",
"@type": "xsd:datetime"
}
}
}
]
}
Examples
1 - CEL Policy Expressions
Enforce policy constraints declaratively with Common Expression Language, without writing Java.
Overview
The EDC policy engine takes a code-first approach: policy constraints are evaluated by Java functions
contributed as extensions. CEL (Common Expression Language) offers a
declarative alternative. Instead of writing, compiling, and deploying a Java function, you register a CEL
expression as a piece of data and bind it to a policy’s left operand. The policy engine evaluates it through a
single dynamic constraint function, so no code needs to be shipped to add or change a rule.
This is particularly useful for credential-based access rules — “the counterparty must hold a valid membership
credential issued by X” — which would otherwise each require a dedicated policy function.
CEL support is experimental and they are bundled in the edc virtual bundles (BOMs). See the
decision record
for the rationale, and the contributor documentation
for the developer-facing details and how to add your own functions.
How CEL expressions are bound
A CEL expression is stored as a CelExpression with, among other fields, a left operand and a CEL
expression string. The link to a policy is the left operand: an ODRL AtomicConstraint’s leftOperand
must be identical to the leftOperand of a registered CelExpression. When the policy engine reaches a
constraint whose left operand matches a stored expression, it evaluates that expression instead of dispatching
to a code-based function.
Left operands are conventionally IRIs, for example
https://w3id.org/example/credentials/MembershipCredential. The value is opaque to the engine — it is only
used as a lookup key — but using a stable, namespaced IRI avoids collisions.
Scopes
Policy rules are only evaluated in the scopes they are bound to. A
CelExpression declares a set of scopes, and the engine binds the expression to exactly those policy phases:
| Scope value | Policy phase |
|---|
catalog | catalog request |
contract.negotiation | contract negotiation |
transfer.process | transfer process |
policy.monitor | policy monitor (ongoing checks) |
If scopes is left empty it defaults to *. (match all scopes). Restrict the scopes to the phases where the
check is actually needed — for example, verifying a membership credential during negotiation but not again
during provisioning — to avoid redundant evaluation.
Binding by action
In addition to the left operand, an expression may declare a set of actions. An expression is also bound
when the evaluated action matches one of its actions entries. This lets a single expression apply across
constraints that share an action rather than a single left operand.
Managing expressions with the Management API
CEL expressions are managed through the cel-api-v5 extension under the /v5beta/celexpressions path of the
Management API. All operations require the management-api:admin authorization scope.
| Operation | Request |
|---|
| Create | POST /v5beta/celexpressions |
| Query | POST /v5beta/celexpressions/request |
| Get by id | GET /v5beta/celexpressions/{id} |
| Update | PUT /v5beta/celexpressions/{id} |
| Delete | DELETE /v5beta/celexpressions/{id} |
| Test | POST /v5beta/celexpressions/test |
The field-by-field schemas are in the
OpenAPI reference; the essentials are below.
Creating an expression
The request body is a JSON-LD CelExpression. Required fields are @context, @type, leftOperand,
expression, and description; @id (a UUID is generated if omitted), scopes, and actions are optional.
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "CelExpression",
"leftOperand": "https://w3id.org/example/credentials/MembershipCredential",
"expression": "ctx.agent.claims.vc.valid().withType('MembershipCredential').hasClaim('status', 'active')",
"description": "Requires a valid MembershipCredential whose subject status is active",
"scopes": [
"catalog",
"contract.negotiation",
"transfer.process"
]
}
Create returns an IdResponse (the @id and createdAt); GET and query return the full object. A sample
body is available at celexpression.membership.json.
Testing an expression
Before binding an expression to a live policy, you can dry-run it with POST /v5beta/celexpressions/test. The
request carries the expression, the constraint triple (leftOperand, operator, rightOperand), and a
params map that stands in for the evaluation context. The response contains a boolean evaluationResult (or
an error if the expression failed to compile or evaluate):
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "CelExpressionTestRequest",
"leftOperand": "https://w3id.org/example/credentials/MembershipCredential",
"expression": "ctx.agent.id == 'did:web:consumer'",
"operator": "EQ",
"rightOperand": "active",
"params": {
"agent": {
"id": "did:web:consumer"
}
}
}
The evaluation context
Expressions are evaluated against a set of bound variables. What is available depends on the scope:
| Variable | Shape | Available in |
|---|
ctx.agent | { id, attributes, claims } — the counterparty; claims.vc is the credential list | catalog, contract.negotiation, transfer.process |
ctx.agreement | { id, assetId, providerId, consumerId, agreementId, contractSigningDate } | transfer.process, policy.monitor |
this | { leftOperand, operator, rightOperand } — the ODRL constraint triple being evaluated | all scopes |
now | the current timestamp | all scopes |
this.rightOperand is the value declared on the policy constraint, so an expression can compare against it
rather than hard-coding a value. Note that ctx.agent is not available in the policy.monitor scope,
which only exposes ctx.agreement.
In CEL, reading a map key that is absent aborts evaluation with an error — it does not return false. Guard
raw map access with the has() macro, or prefer the Verifiable Credential helper functions below, which are
written to return “no match” instead of erroring on missing data.
Verifiable Credential helper functions
When using Decentralized Claims (DCP), the counterparty’s verified credentials are
exposed to expressions as ctx.agent.claims.vc. The decentralized-claims-cel extension registers a set of
helper functions that make credential checks concise and safe. All of them are null- and shape-safe: a
missing key, an absent credential, or a wrongly-typed value yields “no match” rather than an evaluation error.
Without the helpers, a credential check is a nested filter/exists expression:
ctx.agent.claims.vc.filter(c, c.type.exists(t, t == 'MembershipCredential'))
.exists(c, c.credentialSubject.exists(cs, cs.memberOf == 'Catena-X'))
With the helpers, the same check reads:
ctx.agent.claims.vc.withType('MembershipCredential').hasClaim('memberOf', 'Catena-X')
The credential shape
Each entry in ctx.agent.claims.vc is a map with these keys:
| Key | Type | Notes |
|---|
id | string | |
type | list of strings | |
@context | list of strings | |
issuer | map | { id, ...additionalProperties } |
issuanceDate | string (ISO-8601) | |
expirationDate | string (ISO-8601) | present only if the credential declares one |
credentialSubject | list of maps | each is the subject’s claims, with its id merged in |
Available functions
The functions below take the credential list (ctx.agent.claims.vc) as their receiver:
| Function | Result | Meaning |
|---|
withType(t) | list | credentials whose type contains t |
withContext(c) | list | credentials whose @context contains c |
withIssuer(id) | list | credentials issued by id |
valid() | list | credentials already issued and not expired |
hasCredential(t) | bool | whether any credential has type t |
hasClaim(name) | bool | whether any subject has a claim name |
hasClaim(name, value) | bool | whether any subject’s claim name equals value |
claim(name) | dyn | the first value of subject claim name, or null |
claims(name) | list | all values of subject claim name |
withType, withIssuer, withContext, and valid return a filtered list, so they chain:
ctx.agent.claims.vc.valid().withType('MembershipCredential').hasClaim('status', 'active').
A matching set of single-credential overloads (hasType, hasContext, hasClaim, claim, valid) take a
single credential as receiver, so they compose with the standard CEL macros:
ctx.agent.claims.vc.exists(c, c.hasType('MembershipCredential') && c.valid())
claim/hasClaim names may be dotted paths to reach nested subject claims (degree.type). The whole name
is tried as a literal key first, so credential claim keys that are themselves IRIs (and contain dots) remain
addressable.
valid() mirrors the credential validity rule applied by the verification pipeline (a credential is valid once
issued and until it expires). In the standard DCP flow credentials have already been validated before reaching
the policy engine, so valid() is a defence-in-depth check.
End-to-end example
Enforcing “the counterparty must present a valid, active membership credential” takes two objects that share a
left operand.
First, register the CEL expression (celexpression.membership.json):
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "CelExpression",
"leftOperand": "https://w3id.org/example/credentials/MembershipCredential",
"expression": "ctx.agent.claims.vc.valid().withType('MembershipCredential').hasClaim('status', 'active')",
"description": "Requires a valid MembershipCredential whose subject status is active",
"scopes": [
"catalog",
"contract.negotiation",
"transfer.process"
]
}
Then reference the same left operand from a policy constraint
(policy.cel.membership.json):
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "PolicyDefinition",
"policy": {
"@type": "Set",
"permission": [
{
"action": "use",
"constraint": {
"leftOperand": "https://w3id.org/example/credentials/MembershipCredential",
"operator": "eq",
"rightOperand": "active"
}
}
]
}
}
When this policy is evaluated in any of the declared scopes, the engine finds the CEL expression by its left
operand and evaluates it against the counterparty’s credentials. Because the expression’s scopes include
catalog, contract.negotiation, and transfer.process, the same rule is enforced when the catalog is
requested, when a contract is negotiated, and when a transfer starts.
Configuration
CEL adds a single configuration setting, and only when the SQL store is used:
| Setting | Default | Description |
|---|
edc.sql.store.cel.datasource | default | The datasource used by the SQL expression store |
Extending with custom functions
The Verifiable Credential helpers are themselves custom functions registered by the decentralized-claims-cel
extension — you can add your own the same way. This is a developer task; see the
contributor documentation
for how to implement and register a CelFunction.