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.
Contract Negotiation Approval
The policy engine can also decide whether a provider contract negotiation needs to be approved manually before it
reaches an agreement. The contract policy is evaluated in the dedicated approval.contract.negotiation scope, and
negotiations that do not pass it are held until they are approved or rejected through the Management API.
See Contract Negotiation Approval 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 a transfer process is requested if it has already been checked during the contract negotiation.
To avoid an unnecessary check, do not bind the business partner rule to the transfer process 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 scope (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 |
approval.contract.negotiation | contract negotiation approval |
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.
Restricting by operator
When several expressions share a left operand, they are combined with a logical AND: all of them must evaluate to
true for the constraint to be satisfied. By default, every expression is evaluated regardless of the constraint’s
operator. An expression may declare a set of supportedOperators to restrict the operators it is evaluated with:
- If
supportedOperators is empty (the default), the expression is evaluated for any operator. - Otherwise, the expression is evaluated only when the constraint’s operator is in the set; for other operators it is
skipped.
- If none of the expressions registered for a left operand supports the constraint’s operator, the constraint
evaluates to
false (fail closed).
Operators are given as the operator names EQ, NEQ, GT, GEQ, LT, LEQ, IS_PART_OF, HAS_PART, IS_A,
IS_ALL_OF, IS_ANY_OF, and IS_NONE_OF. The deprecated IN is treated as IS_PART_OF.
This lets you register different logic per operator for the same left operand, for example an equality check for
EQ and a membership check for IS_PART_OF:
[
{
"@type": "CelExpression",
"leftOperand": "https://w3id.org/example/region",
"expression": "ctx.agent.attributes.region == this.rightOperand",
"description": "The counterparty must be in the given region",
"supportedOperators": ["EQ"]
},
{
"@type": "CelExpression",
"leftOperand": "https://w3id.org/example/region",
"expression": "ctx.agent.attributes.region in this.rightOperand",
"description": "The counterparty must be in one of the given regions",
"supportedOperators": ["IS_PART_OF"]
}
]
Policy definitions are validated against supportedOperators too: creating or updating a policy definition fails
if a constraint uses an operator that none of the expressions for its left operand supports.
An expression that must always apply to a left operand, such as a mandatory membership check, should keep
supportedOperators empty. A restricted expression is skipped silently for other operators.
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, actions, and
supportedOperators 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, approval.contract.negotiation, transfer.process, policy.monitor |
ctx.agreement | { id, assetId, providerId, consumerId, agreementId, contractSigningDate } | transfer.process, policy.monitor |
ctx.asset | { id, participantContextId, properties, privateProperties } — the negotiated asset | approval.contract.negotiation |
ctx.contractDefinition | { id, participantContextId, accessPolicyId, contractPolicyId, privateProperties, assetsSelector } | approval.contract.negotiation |
this | { leftOperand, operator, odrlOperator, rightOperand } — the ODRL constraint 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.
The constraint operator is available in two forms. this.operator holds the operator name (EQ, IS_PART_OF, …).
this.odrlOperator holds the ODRL term without the namespace (eq, isPartOf, …), matching what policy authors
write, and reports both IN and IS_PART_OF as isPartOf. A single expression can branch on it:
this.odrlOperator == 'eq' ? ctx.agent.attributes.region == this.rightOperand
: ctx.agent.attributes.region in this.rightOperand
Alternatively, split the logic into separate expressions with supportedOperators.
In the policy.monitor and approval.contract.negotiation scopes there is no incoming request, so ctx.agent is
rebuilt from the claims stored when they were received: on the contract agreement for the policy monitor (with the
consumer as identity), and on the contract negotiation for the approval. The claims, credentials included, are not
verified again at evaluation time.
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 |
Partners
In multi-participant runtimes, ctx.partners gives expressions access to the partners and partner groups of the
participant context, e.g. ctx.partners.byIdentity(ctx.agent.id).inGroup('gold'), see
Partners and Partner Groups.
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.
2 - Partners and Partner Groups
Record the counter-parties a participant knows and group them, then use them in policies.
Overview
Policies often depend on who the counter-party is to the provider: a customer of a given tier, a partner with a
contract number, a member of an allow-list. Partners and partner groups record this knowledge per participant
context, so that policies can use it without custom code:
- a
PartnerGroup has an id, a name, a description and free-form properties, e.g. gold-customers; - a
Partner has an id, the identity of the counter-party (its id in the dataspace, e.g. its DID, as the dataspace
profile extracts it from the verified token), a name, free-form properties, e.g. a customer number, and the
groupIds of the groups it belongs to.
Partners and groups belong to one participant context. Within it, a partner’s identity is unique, a partner can only
reference existing groups, and a group can’t be deleted while partners reference it (409).
Partners are available in the multi-participant runtime (module partner-cel, in controlplane-virtual-base-bom),
stored in SQL with controlplane-virtual-feature-sql-bom (edc.sql.store.partner.datasource).
Managing partners and groups
The v5 Management API manages them, with the scopes management-api:partners:read and management-api:partners:write:
| Resource | Endpoints |
|---|
| Partner groups | POST /v5/participants/{participantContextId}/partnergroups, GET, PUT, DELETE …/partnergroups/{id}, POST …/partnergroups/request (query) |
| Partners | POST /v5/participants/{participantContextId}/partners, GET, PUT, DELETE …/partners/{id}, POST …/partners/request (query) |
{
"@context": [ "https://w3id.org/edc/connector/management/v2" ],
"@type": "PartnerGroup",
"@id": "gold",
"name": "Gold customers"
}
{
"@context": [ "https://w3id.org/edc/connector/management/v2" ],
"@type": "Partner",
"@id": "partner-0001",
"identity": "did:web:customer.example.com",
"name": "Customer Inc.",
"properties": {
"businessId": "BID-0001"
},
"groupIds": [ "gold" ]
}
Partners can be queried by their properties (properties.businessId = BID-0001) and by group
(groupIds contains gold).
Using partners in CEL expressions
CEL expressions evaluated in the catalog, contract.negotiation,
approval.contract.negotiation, transfer.process and policy.monitor scopes get a ctx.partners handle, which
looks up the partners of the participant context the policy is evaluated for:
| Expression | Result |
|---|
ctx.partners.byIdentity(s) | the partner with the identity s |
ctx.partners.byId(s) | the partner with the id s |
ctx.partners.query({'businessId': 'X'}) | the partners whose properties match all the given values (at most 1000) |
p.inGroup(s) | whether the partner p belongs to the group s |
p.groups | the group ids of the partner p |
p.found | whether the partner exists |
A lookup never fails: an unknown partner has found == false and no groups. An expression that grants access to the
members of a group is therefore simply:
ctx.partners.byIdentity(ctx.agent.id).inGroup('gold')
Because the expression is evaluated again in the policy.monitor scope, removing a partner from the group also
terminates its running transfers that depend on it. The contract negotiation approval
uses the same handle to decide which negotiations need a manual approval.
Using partners in Java policy functions
Custom policy functions inject the PartnerService and look up partners with findById, findByIdentity or search,
with the participant context id of the policy context (ParticipantContextPolicyContext).
3 - Contract Negotiation Approval
Hold provider contract negotiations for manual approval, driven by the contract policy.
Overview
By default, a provider accepts every contract request whose offer passes policy evaluation, and the contract
negotiation moves straight on to the agreement. Many dataspaces need a human (or an external system) to approve
some negotiations first, for example requests from counterparties that are not known business partners.
With contract negotiation approval, a provider negotiation in the REQUESTED state can be held before the
agreement is sent. Whether a negotiation needs approval is decided by the policy engine: the
contract policy is evaluated in a dedicated scope, and a negotiation that does not pass it waits until it is
approved or rejected through the Management API.
Approval works with both control plane flavours: the classic runtime (state machine) and the virtual runtime
(task executor). See the
decision record
for the rationale.
Enabling approval
Approval is opt-in and disabled by default:
| Setting | Default | Description |
|---|
edc.negotiation.approval.enabled | false | Holds provider negotiations whose contract policy fails the approval scope |
The setting applies to the whole runtime. In a virtual runtime it is enabled for all participant contexts, but
only negotiations whose contract policy contains approval constraints can actually be held (see below).
Writing approval rules
The approval scope
When a provider negotiation is in REQUESTED, the contract policy stored on the negotiation (the policy of the
last contract offer) is evaluated in the approval.contract.negotiation
scope:
- the evaluation succeeds: the negotiation is approved automatically and proceeds to the agreement;
- the evaluation fails: the negotiation is held for manual approval.
Only constraints explicitly bound to approval.contract.negotiation are evaluated, with a CEL expression whose
scopes contain it or with a Java function bound to it. The scope deliberately does not start with
contract.negotiation, so it does not inherit the rules bound to the contract negotiation scope: those were already
checked when the request was received. All other constraints are filtered out, so a contract policy without approval
constraints always passes, and existing offers are not affected.
Approval constraints are part of the contract policy, so the consumer always sees them in the catalog and in the
contract agreement, like any other constraint of that policy. What the constraint reveals is up to the provider: it can
declare the approval openly, or be an opaque reference to a rule whose criteria stay on the provider side.
What approval rules can use
Approval rules are evaluated by the provider, against the following data:
| Data | CEL | Java (ApprovalContractNegotiationPolicyContext) |
|---|
| The constraint being evaluated (left operand, operator, right operand) | this | the function arguments |
| The counterparty’s identity and the claims of its last contract request | ctx.agent | participantAgent() |
| The participant context, for the partner functions | ctx.partners | participantContextId() |
The negotiated asset: { id, participantContextId, properties, privateProperties } | ctx.asset | asset() (AssetPolicyContext) |
The contract definition that offered it: { id, participantContextId, accessPolicyId, contractPolicyId, privateProperties, assetsSelector } | ctx.contractDefinition | contractDefinition() (ContractDefinitionPolicyContext) |
A few things to keep in mind:
- there is no incoming request when the rule is evaluated. The counterparty’s claims are the ones captured, and
verified, when the provider received its last contract request: they are stored on the negotiation and refreshed on
every request of the counterparty;
- private properties of the asset and of the contract definition are available, as the rules are evaluated by the
provider only. The data address of the asset is not exposed;
- when the asset or the contract definition does not exist anymore, it is not available: rules reading it fail, and
the negotiation is held;
- property keys are expanded IRIs, so they are accessed with the map syntax, e.g.
ctx.asset.properties['https://w3id.org/edc/v0.0.1/ns/manualApproval']. Reading an absent key aborts the
evaluation, so check it with in first.
Public rules: declaring manual approval in the offer
The simplest way to require manual approval is to declare it in the offer itself. The contract policy carries a
constraint whose meaning is explicit, and a single CEL expression evaluates its right operand. The consumer reads in
the catalog that negotiating this offer needs a manual approval by the provider, and can plan for it.
Register the expression once (celexpression.manualapproval.json):
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "CelExpression",
"leftOperand": "https://w3id.org/example/approval/manualApproval",
"expression": "this.rightOperand != 'required'",
"description": "Holds the negotiations of the offers that declare a required manual approval",
"scopes": [
"approval.contract.negotiation"
]
}
and declare the approval in the contract policies that need it
(policy.manualapproval.json):
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "PolicyDefinition",
"policy": {
"@type": "Set",
"permission": [
{
"action": "use",
"constraint": {
"leftOperand": "https://w3id.org/example/approval/manualApproval",
"operator": "eq",
"rightOperand": "required"
}
}
]
}
}
The decision depends only on what the offer declares: negotiations of offers with manualApproval eq required are
held, while offers that declare another value (or no manualApproval constraint at all) are approved automatically.
Rules based on the counterparty
Rules can also decide based on who is negotiating, keeping the criteria private: the constraint in the contract policy
only references the rule. The following expression (celexpression.approval.json) only
lets negotiations from an allow list of counterparties through without approval:
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "CelExpression",
"leftOperand": "https://w3id.org/example/approval/TrustedCounterParty",
"expression": "ctx.agent.id in ['did:web:partner-a.example.com', 'did:web:partner-b.example.com']",
"description": "Negotiations from counterparties outside the allow list need manual approval",
"scopes": [
"approval.contract.negotiation"
]
}
The contract policy then references the same left operand (policy.approval.json):
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "PolicyDefinition",
"policy": {
"@type": "Set",
"permission": [
{
"action": "use",
"constraint": {
"leftOperand": "https://w3id.org/example/approval/TrustedCounterParty",
"operator": "eq",
"rightOperand": "true"
}
}
]
}
}
A request from did:web:partner-a.example.com is agreed automatically, any other counterparty is held.
If partners and partner groups are available in the runtime, the same rule can be expressed
without hard-coding identities:
ctx.partners.byIdentity(ctx.agent.id).inGroup('trusted')
Rules can also rely on the counterparty’s credentials, through the claims stored on the negotiation and the
Verifiable Credential helper functions. With Decentralized
Claims (DCP), the following expression approves automatically the negotiations of counterparties holding a valid,
active MembershipCredential, and holds all the others:
ctx.agent.claims.vc.valid().withType('MembershipCredential').hasClaim('status', 'active')
Rules based on the asset or the contract definition
Rules can depend on what is negotiated: the asset, for a check on a single asset, or the contract definition that
offered it, for a rule shared by all its assets. The following expression holds the negotiations of the assets with a
manualApproval property:
!('https://w3id.org/edc/v0.0.1/ns/manualApproval' in ctx.asset.properties)
and this one holds all the negotiations of the contract definitions with a manualApproval private property:
!('https://w3id.org/edc/v0.0.1/ns/manualApproval' in ctx.contractDefinition.privateProperties)
The data can be combined with the counterparty, e.g. to require approval for the assets with a manualApproval
property, unless the counterparty is a trusted partner:
!('https://w3id.org/edc/v0.0.1/ns/manualApproval' in ctx.asset.properties)
|| ctx.partners.byIdentity(ctx.agent.id).inGroup('trusted')
Rules as Java policy functions
Approval rules can also be implemented as regular policy functions. Bind the left operand to the approval scope, and
register the function for ApprovalContractNegotiationPolicyContext to access the asset and the contract definition:
public class ApprovalExtension implements ServiceExtension {
private static final String TRUSTED_KEY = "https://w3id.org/example/approval/TrustedCounterParty";
@Inject
private PolicyEngine policyEngine;
@Override
public void initialize(ServiceExtensionContext context) {
policyEngine.bindScope(TRUSTED_KEY, ApprovalContractNegotiationPolicyContext.APPROVAL_SCOPE);
policyEngine.registerFunction(ApprovalContractNegotiationPolicyContext.class, Permission.class, TRUSTED_KEY,
(operator, rightValue, permission, ctx) -> isTrusted(ctx.participantAgent().getIdentity(), ctx.asset()));
}
}
Functions registered for ContractNegotiationPolicyContext are used for the approval scope too, as the approval
context extends it. They can check ctx instanceof AssetPolicyContext or ctx instanceof ContractDefinitionPolicyContext
to access the asset and the contract definition.
Held negotiations
A held negotiation stays in the REQUESTED state with pending set to true, which is now part of the contract
negotiation returned by the Management API:
{
"@type": "ContractNegotiation",
"@id": "a5a8b9f2-6a2c-4a39-9d55-0b6d1cdbbd0e",
"type": "PROVIDER",
"state": "REQUESTED",
"pending": true,
"counterPartyId": "did:web:unknown.example.com"
}
A contract.negotiation.held event is published when a negotiation is held. Held negotiations can be listed with
the contract negotiation query endpoint, using the filter pending = true.
Approving and rejecting
A held negotiation is approved or rejected with the following endpoints:
| Management API | Endpoint |
|---|
| v5 | POST /v5/participants/{participantContextId}/contractnegotiations/{id}/approve |
| v5 | POST /v5/participants/{participantContextId}/contractnegotiations/{id}/reject |
| v4 | POST /v4/contractnegotiations/{id}/approve |
| v4 | POST /v4/contractnegotiations/{id}/reject |
Approve has no request body. Reject requires the following body:
{
"@context": [
"https://w3id.org/edc/connector/management/v2"
],
"@type": "RejectNegotiation"
}
Both operations only apply to a provider negotiation in the REQUESTED state that is pending: any other
negotiation results in a 409 Conflict. On the v5 API they require the management-api:negotiations:write scope.
- Approve moves the negotiation to
AGREEING, and the agreement is sent to the consumer. A
contract.negotiation.approved event is published. - Reject moves the negotiation to
TERMINATING, and a termination message is sent to the consumer. A
contract.negotiation.rejected event is published.
The termination reason is sent to the counterparty, so a rejection always uses the fixed reason
Negotiation manually rejected, to avoid disclosing any provider-internal information.
Getting notified
The contract.negotiation.held, contract.negotiation.approved and contract.negotiation.rejected events can be
delivered to an external system, such as an approval UI, with callbacks. Provider negotiations are created from
protocol messages, so they have no per-request callback addresses: use
participant callbacks or
static callbacks instead. For example, the following
participant callback (a participant context configuration entry in the virtual runtime, a runtime setting in the
classic runtime) receives the held negotiations:
edc.callbacks=[{"uri": "https://approvals.example.com/hook", "events": ["contract.negotiation.held"], "transactional": false}]
The callback receives the event with the id of the provider negotiation (payload.contractNegotiationId), which
is the id to use with the approve and reject endpoints.
Custom pending guards
Approval is built on the contract negotiation pending guards. Extensions can hold negotiations with their own
logic by registering a ContractNegotiationPendingGuard in the ContractNegotiationPendingGuardRegistry. All the
registered guards are evaluated, and a negotiation is held when any of them matches. Providing a single
ContractNegotiationPendingGuard as a service still works but is deprecated in favour of the registry.
Note that the approve and reject endpoints only apply to provider negotiations held in REQUESTED: negotiations
held by custom guards in other states need to be resumed by the extension itself.
Limitations
- Only provider negotiations in the
REQUESTED state can be approved; consumer-side approval, approval in other
states and transfer process approval are not supported. - The claims used for approval are the ones of the latest contract request received by the provider: they are not
verified again when the negotiation is approved.
edc.negotiation.approval.enabled applies to the whole runtime.