diff --git a/content/en/documentation/for-adopters/control-plane/_index.md b/content/en/documentation/for-adopters/control-plane/_index.md index 3a8d8b0..40435e2 100644 --- a/content/en/documentation/for-adopters/control-plane/_index.md +++ b/content/en/documentation/for-adopters/control-plane/_index.md @@ -355,6 +355,10 @@ If a negotiation is successfully completed (termed *finalized*), a DSP contract } ``` +A provider can also hold contract negotiations for manual approval before the agreement is sent, for example for +requests from unknown counterparties. Which negotiations need approval is decided by the contract policy, see +[Contract Negotiation Approval](./policy-engine/negotiation-approval/_index.md). + EDC implements DSP message exchanges using a reliable quality of service. That is, all message operations and state machine transitions are performed reliably in a transaction context. EDC will only commit a state machine transition if a message is successfully acknowledged by the counterparty. If a send operation fails, the associated transition will be rolled back, and the message will be resent. As with all reliable messaging systems, EDC operations are idempotent. ### Working with Asynchronous Messaging and Events diff --git a/content/en/documentation/for-adopters/control-plane/policy-engine/_index.md b/content/en/documentation/for-adopters/control-plane/policy-engine/_index.md index 9535bb2..516bfb9 100644 --- a/content/en/documentation/for-adopters/control-plane/policy-engine/_index.md +++ b/content/en/documentation/for-adopters/control-plane/policy-engine/_index.md @@ -5,6 +5,7 @@ weight: 10 * [CEL Policy Expressions](#cel-policy-expressions) + * [Contract Negotiation Approval](#contract-negotiation-approval) * [Policy Scopes and Bindings](#policy-scopes-and-bindings) * [Designing for Optimal Policy Performance](#designing-for-optimal-policy-performance) * [In Force Policy](#in-force-policy) @@ -106,6 +107,14 @@ credential-based rules, which ship with a set of ready-made helper functions. See [CEL Policy Expressions](./cel/_index.md) 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](./negotiation-approval/_index.md) 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 diff --git a/content/en/documentation/for-adopters/control-plane/policy-engine/cel/_index.md b/content/en/documentation/for-adopters/control-plane/policy-engine/cel/_index.md index c7d7fb1..c75a0d7 100644 --- a/content/en/documentation/for-adopters/control-plane/policy-engine/cel/_index.md +++ b/content/en/documentation/for-adopters/control-plane/policy-engine/cel/_index.md @@ -55,12 +55,13 @@ used as a lookup key — but using a stable, namespaced IRI avoids collisions. Policy rules are only evaluated in the [scopes](../_index.md#policy-scopes-and-bindings) 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) | +| Scope value | Policy phase | +|---------------------------------|--------------------------------------------------------------------| +| `catalog` | catalog request | +| `contract.negotiation` | contract negotiation | +| `approval.contract.negotiation` | [contract negotiation approval](../negotiation-approval/_index.md) | +| `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 @@ -187,12 +188,14 @@ an `error` if the expression failed to compile or evaluate): 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, odrlOperator, rightOperand }` — the ODRL constraint being evaluated | all scopes | -| `now` | the current timestamp | all scopes | +| 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. @@ -206,8 +209,12 @@ 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`](#restricting-by-operator). Note that `ctx.agent` is **not** available in the `policy.monitor` scope, -which only exposes `ctx.agreement`. +Alternatively, split the logic into separate expressions with [`supportedOperators`](#restricting-by-operator). + +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 diff --git a/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/_index.md b/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/_index.md new file mode 100644 index 0000000..125de50 --- /dev/null +++ b/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/_index.md @@ -0,0 +1,346 @@ +--- +title: Contract Negotiation Approval +description: Hold provider contract negotiations for manual approval, driven by the contract policy. +weight: 30 +--- + + + * [Overview](#overview) + * [Enabling approval](#enabling-approval) + * [Writing approval rules](#writing-approval-rules) + * [The approval scope](#the-approval-scope) + * [What approval rules can use](#what-approval-rules-can-use) + * [Public rules: declaring manual approval in the offer](#public-rules-declaring-manual-approval-in-the-offer) + * [Rules based on the counterparty](#rules-based-on-the-counterparty) + * [Rules based on the asset or the contract definition](#rules-based-on-the-asset-or-the-contract-definition) + * [Rules as Java policy functions](#rules-as-java-policy-functions) + * [Held negotiations](#held-negotiations) + * [Approving and rejecting](#approving-and-rejecting) + * [Getting notified](#getting-notified) + * [Custom pending guards](#custom-pending-guards) + * [Limitations](#limitations) + + +## 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](../_index.md): 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](https://github.com/eclipse-edc/Connector/tree/main/docs/developer/decision-records/2026-10-07-contract-negotiation-approval) +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](../_index.md#policy-scopes-and-bindings): + +- 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](./celexpression.manualapproval.json)): + +```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](./policy.manualapproval.json)): + +```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](./celexpression.approval.json)) only +lets negotiations from an allow list of counterparties through without approval: + +```json +{ + "@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](./policy.approval.json)): + +```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](../cel/_index.md#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: + +```java +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: + +```json +{ + "@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: + +```json +{ + "@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](../../../observability/_index.md#4-participant-callbacks) or +[static callbacks](../../../observability/_index.md#3-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: + +```properties +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. diff --git a/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/celexpression.approval.json b/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/celexpression.approval.json new file mode 100644 index 0000000..2b6fb91 --- /dev/null +++ b/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/celexpression.approval.json @@ -0,0 +1,12 @@ +{ + "@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" + ] +} diff --git a/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/celexpression.manualapproval.json b/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/celexpression.manualapproval.json new file mode 100644 index 0000000..b5e7784 --- /dev/null +++ b/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/celexpression.manualapproval.json @@ -0,0 +1,12 @@ +{ + "@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" + ] +} diff --git a/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/policy.approval.json b/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/policy.approval.json new file mode 100644 index 0000000..90c85a6 --- /dev/null +++ b/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/policy.approval.json @@ -0,0 +1,19 @@ +{ + "@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" + } + } + ] + } +} diff --git a/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/policy.manualapproval.json b/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/policy.manualapproval.json new file mode 100644 index 0000000..3b79eb0 --- /dev/null +++ b/content/en/documentation/for-adopters/control-plane/policy-engine/negotiation-approval/policy.manualapproval.json @@ -0,0 +1,19 @@ +{ + "@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" + } + } + ] + } +} diff --git a/content/en/documentation/for-adopters/observability/_index.md b/content/en/documentation/for-adopters/observability/_index.md index 015824b..045a43a 100644 --- a/content/en/documentation/for-adopters/observability/_index.md +++ b/content/en/documentation/for-adopters/observability/_index.md @@ -14,13 +14,14 @@ weight: 85 * [3.1 Configuration reference](#31-configuration-reference) * [3.2 Authentication](#32-authentication) * [3.3 Transactional callbacks](#33-transactional-callbacks) + * [4. Participant callbacks](#4-participant-callbacks) * [Choosing the right approach](#choosing-the-right-approach) ## Overview EDC processes are asynchronous. State changes — such as a contract negotiation reaching agreement or a transfer process -starting — are communicated through an internal eventing system. There are three ways to observe these state changes +starting — are communicated through an internal eventing system. There are four ways to observe these state changes from outside the runtime or from within a custom extension: | Approach | Where the consumer runs | How it is registered | @@ -28,6 +29,7 @@ from outside the runtime or from within a custom extension: | In-process event subscriber | Same JVM as the EDC runtime | Custom extension, injected `EventRouter` | | Per-request webhooks | External HTTP endpoint | `callbackAddresses` in Management API request bodies | | Static callbacks | External HTTP endpoint | `edc.callback.*` configuration properties | +| Participant callbacks | External HTTP endpoint | `edc.callbacks` participant context configuration entry | ## 1. In-process event subscribers @@ -170,6 +172,11 @@ edc.callback.audit.auth.codeid=my-api-key-alias > `auth.codeid` is required when `auth.key` is set. Providing `auth.key` without `auth.codeid` will cause the runtime > to fail at startup. +Static callbacks belong to the runtime, so their secrets are always resolved from the **default vault partition**, +even for events of a specific participant context. Per-request webhooks and +[participant callbacks](#4-participant-callbacks) resolve their secrets from the vault partition of the participant +context that owns the event instead. + ### 3.3 Transactional callbacks Setting `transactional=true` causes the callback to be dispatched synchronously, within the transaction that triggered @@ -177,11 +184,54 @@ the event. A failure (e.g. the endpoint is unreachable) will propagate back as a state machine to retry the transition. Use this only when the callback endpoint is highly reliable and you need guaranteed delivery. For most monitoring use cases, leave `transactional=false`. +## 4. Participant callbacks + +Participant callbacks are configured per participant context, and apply to all the matching events of that +participant context. They are useful when the events are not triggered by a Management API request of the +participant, so no per-request webhook can be attached: for example, a provider negotiation created by a contract +request of a counterparty, which can be [held for manual approval](../control-plane/policy-engine/negotiation-approval/_index.md). + +The callbacks are read from the `edc.callbacks` entry of the participant context configuration. Its value is a JSON +array of callback addresses, with the same fields as the per-request webhooks: + +```json +[ + { + "uri": "https://approvals.example.com/hook", + "events": [ + "contract.negotiation.held", + "contract.negotiation.approved", + "contract.negotiation.rejected" + ], + "transactional": false, + "authKey": "Authorization", + "authCodeId": "approvals-api-key" + } +] +``` + +| Field | Description | Required | Default | +|---|---|---|---| +| `uri` | The HTTP endpoint to call | yes | — | +| `events` | Event type prefixes to subscribe to | yes | — | +| `transactional` | Whether to invoke the callback within the processing transaction (see [Transactional callbacks](#33-transactional-callbacks)) | no | `false` | +| `authKey` | The HTTP header name to use for authentication | no | — | +| `authCodeId` | Alias of the secret to send in the authentication header, resolved from the participant context's vault partition | no | — | + +In the virtual runtime, the entry is set through the participant context configuration Management API. In the +classic runtime, the participant context configuration is the runtime configuration, so `edc.callbacks` is set as a +regular setting. + +Changes to the configuration apply immediately, as the callbacks are not stored on the processes. An invalid +`edc.callbacks` value is logged as a warning and ignored, so it never affects event processing. + ## Choosing the right approach - Use **in-process event subscribers** when you are already writing a custom EDC extension and need the lowest latency or transactional semantics. - Use **per-request webhooks** when you want to track specific operations triggered from a controller or orchestration layer that already constructs the Management API calls. +- Use **participant callbacks** when each participant context needs its own sink for a class of events, including + processes that are not started through the Management API, such as provider contract negotiations. - Use **static callbacks** when you want a centralized, always-on sink for a class of events without modifying individual API requests or shipping code to the runtime.