Invocable Apex wrapper for Flow, Apex-trigger orchestration, and Agentforce pre-execution checks.
force-app/main/default/classes/DecionisActionGate.clsThis is the literal Salesforce wrapper layer around the Decionis universal Action Gate and portable Decision Dossier APIs: an invocable Flow action named Evaluate Action with Decionis, a record-side Decision Dossier LWC, a starter permission set, Apex tests, and a deployable package manifest.
Invocable Apex wrapper for Flow, Apex-trigger orchestration, and Agentforce pre-execution checks.
force-app/main/default/classes/DecionisActionGate.clsRecord-side Lightning Web Component that renders outcome, confidence, policy version, signature, and verification link on Salesforce objects.
force-app/main/default/lwc/decionisDecisionDossier/*Permission set that grants access to the Apex wrappers and the Decision Dossier component.
force-app/main/default/permissionsets/DecionisGovernanceStarter.permissionset-meta.xmlPackage manifest for Salesforce DX or unlocked-package deployment of the starter assets.
manifest/package.xmlThe wrapper entry point exposed to Flow and Agentforce. It forwards Salesforce action context to the Decionis Action Gate and maps back decision, dossier, and verification fields.
// Global so the invocable action and its variables are visible to Flow Builder,
// Agentforce, and Apex in subscriber orgs once installed as a managed package.
global with sharing class DecionisActionGate {
global class Request {
@InvocableVariable(required=true)
global String orgId;
// Either endpointBaseUrl + apiKey, or namedCredential. When namedCredential is set it
// wins: the callout runs as callout:<namedCredential> and the Named Credential's
// external credential supplies auth, so no API key transits Flow configuration.
@InvocableVariable
global String endpointBaseUrl;
@InvocableVariable
global String apiKey;
@InvocableVariable(
label='Named Credential'
description='Name of a Salesforce Named Credential pointing at the Decionis API. Preferred over endpointBaseUrl + apiKey.'
)
global String namedCredential;
@InvocableVariable(required=true)
global String objectType;
@InvocableVariable(required=true)
global String recordId;
@InvocableVariable
global String recordName;
@InvocableVariable(required=true)
global String actionName;
@InvocableVariable
global String workflowKey;
@InvocableVariable
global String decisionType;
@InvocableVariable
global Decimal amount;
@InvocableVariable
global Decimal riskScore;
@InvocableVariable(
label='Mode'
description='SHADOW (observe only), PARALLEL, or ENFORCEMENT. Defaults to the org gate configuration when blank.'
)
global String mode;
@InvocableVariable
global String policyVersion;
@InvocableVariable
global String objectiveProfile;
@InvocableVariable
global String contextJson;
}
global class Response {
@InvocableVariable global String decision;
@InvocableVariable global Boolean continueAction;
@InvocableVariable global Boolean escalateAction;
@InvocableVariable global Boolean delayAction;
@InvocableVariable global String dossierId;
@InvocableVariable global String verificationUrl;
@InvocableVariable global String verificationSignature;
@InvocableVariable global String summary;
@InvocableVariable global String rawResponse;
@InvocableVariable global String errorMessage;
}
private static String asString(Object value) {
return value == null ? null : String.valueOf(value);
}
private static Boolean asBoolean(Object value) {
if (value == null) {
return false;
}
if (value instanceof Boolean) {
return (Boolean) value;
}
return 'true'.equalsIgnoreCase(String.valueOf(value));
}
private static Map<String, Object> parseContext(String contextJson) {
if (String.isBlank(contextJson)) {
return new Map<String, Object>();
}
Object parsed = JSON.deserializeUntyped(contextJson);
if (parsed instanceof Map<String, Object>) {
return (Map<String, Object>) parsed;
}
return new Map<String, Object>();
}
@InvocableMethod(
label='Evaluate Action with Decionis'
description='Calls the Decionis Salesforce Action Gate before a consequential action executes.'
)
global static List<Response> evaluate(List<Request> requests) {
List<Response> responses = new List<Response>();
// Events are accumulated and published once after every callout has completed, so no
// callout ever follows DML inside this transaction.
List<Decionis_Decision__e> decisionEvents = new List<Decionis_Decision__e>();
for (Request request : requests) {
Response responseItem = new Response();
try {
if (String.isBlank(request.namedCredential) && String.isBlank(request.endpointBaseUrl)) {
responseItem.errorMessage =
'Provide either namedCredential or endpointBaseUrl (with apiKey) for the Decionis Action Gate.';
responses.add(responseItem);
continue;
}
Map<String, Object> payload = new Map<String, Object>{
'object_type' => request.objectType,
'record_id' => request.recordId,
'record_name' => request.recordName,
'action_name' => request.actionName,
'workflow_key' => request.workflowKey,
'decision_type' => request.decisionType,
'amount' => request.amount,
'risk_score' => request.riskScore,
'mode' => request.mode,
'policy_version' => request.policyVersion,
'objective_profile' => request.objectiveProfile,
'context' => parseContext(request.contextJson)
};
DecionisGatewayClient.GatewayResult gatewayResult = DecionisGatewayClient.post(
request.endpointBaseUrl,
request.namedCredential,
'/v1/orgs/' +
EncodingUtil.urlEncode(request.orgId, 'UTF-8') +
'/salesforce/action-gate',
request.apiKey,
payload
);
responseItem.rawResponse = gatewayResult.rawBody;
if (gatewayResult.statusCode >= 300) {
responseItem.errorMessage =
'Decionis action gate returned HTTP ' + String.valueOf(gatewayResult.statusCode);
responses.add(responseItem);
continue;
}
Map<String, Object> body = gatewayResult.body;
Map<String, Object> actionGate = body.containsKey('action_gate') &&
body.get('action_gate') instanceof Map<String, Object>
? (Map<String, Object>) body.get('action_gate')
: new Map<String, Object>();
Map<String, Object> verification = body.containsKey('verification')
&& body.get('verification') instanceof Map<String, Object>
? (Map<String, Object>) body.get('verification')
: new Map<String, Object>();
responseItem.decision = asString(body.get('decision'));
responseItem.continueAction = asBoolean(body.get('continue_action'));
responseItem.escalateAction = asBoolean(body.get('escalate_action'));
responseItem.delayAction = asBoolean(body.get('delay_action'));
responseItem.dossierId = asString(actionGate.get('dossier_id'));
responseItem.verificationUrl = asString(verification.get('verification_page_url'));
responseItem.verificationSignature = asString(verification.get('signature'));
responseItem.summary = asString(body.get('summary'));
decisionEvents.add(
new Decionis_Decision__e(
Record_Id__c = request.recordId,
Object_Type__c = request.objectType,
Action_Name__c = request.actionName,
Outcome__c = responseItem.decision,
Dossier_Id__c = responseItem.dossierId,
Verification_URL__c = responseItem.verificationUrl,
Mode__c = request.mode
)
);
} catch (Exception ex) {
responseItem.errorMessage = ex.getMessage();
}
responses.add(responseItem);
}
if (!decisionEvents.isEmpty()) {
try {
EventBus.publish(decisionEvents);
} catch (Exception publishEx) {
// Best-effort: the governed verdict must reach the Flow even if event
// delivery is unavailable in this org.
}
}
return responses;
}
}
The record-side component that loads the portable Decision Dossier artifact and shows the verification state directly on the record.
import { LightningElement, api, wire } from "lwc";
import { getRecord } from "lightning/uiRecordApi";
import getPortableDossier from "@salesforce/apex/DecionisDecisionDossierController.getPortableDossier";
export default class DecionisDecisionDossier extends LightningElement {
@api recordId;
@api objectApiName;
@api orgId;
@api endpointBaseUrl;
@api apiKey;
@api namedCredential;
@api dossierIdField = "Decionis_Dossier_Id__c";
@api outcomeField = "Decionis_Outcome__c";
@api confidenceField = "Decionis_Confidence__c";
@api policyVersionField = "Decionis_Policy_Version__c";
recordData;
portable;
errorMessage;
isLoading = false;
loadedDossierId;
get recordFields() {
if (!this.objectApiName) {
return [];
}
return [
`${this.objectApiName}.${this.dossierIdField}`,
`${this.objectApiName}.${this.outcomeField}`,
`${this.objectApiName}.${this.confidenceField}`,
`${this.objectApiName}.${this.policyVersionField}`,
];
}
@wire(getRecord, { recordId: "$recordId", optionalFields: "$recordFields" })
wiredRecord({ error, data }) {
if (error) {
this.errorMessage = "Unable to read the Salesforce record.";
return;
}
if (!data) {
return;
}
this.recordData = data;
const dossierId = this.recordFieldValue(this.dossierIdField);
if (dossierId && dossierId !== this.loadedDossierId) {
this.loadPortableDossier(dossierId);
}
}
recordFieldValue(fieldApiName) {
if (!this.recordData || !this.recordData.fields) {
return null;
}
const fieldKey = fieldApiName.includes(".") ? fieldApiName.split(".").pop() : fieldApiName;
const fieldValue = this.recordData.fields[fieldKey];
return fieldValue ? fieldValue.value : null;
}
async loadPortableDossier(dossierId) {
if (!this.orgId || (!this.namedCredential && (!this.endpointBaseUrl || !this.apiKey))) {
this.errorMessage =
"Configure orgId plus either namedCredential or endpointBaseUrl + apiKey on the component.";
return;
}
this.isLoading = true;
this.errorMessage = null;
try {
this.portable = await getPortableDossier({
orgId: this.orgId,
dossierId,
endpointBaseUrl: this.endpointBaseUrl,
apiKey: this.apiKey,
namedCredential: this.namedCredential,
});
this.loadedDossierId = dossierId;
} catch (error) {
this.errorMessage =
error && error.body && error.body.message
? error.body.message
: "Unable to load the portable Decision Dossier.";
} finally {
this.isLoading = false;
}
}
get hasArtifact() {
return Boolean(this.portable && this.portable.portable);
}
get displayOutcome() {
return (
this.portable?.portable?.machine_readable?.outcome ||
this.recordFieldValue(this.outcomeField) ||
"N/A"
);
}
get displayConfidence() {
return (
this.portable?.portable?.machine_readable?.confidence_percent ||
this.recordFieldValue(this.confidenceField) ||
"N/A"
);
}
get displayPolicyVersion() {
return (
this.portable?.portable?.machine_readable?.policy_version ||
this.recordFieldValue(this.policyVersionField) ||
"N/A"
);
}
get displaySignalsEvaluated() {
const value = this.portable?.portable?.machine_readable?.signals_evaluated;
return value === undefined || value === null ? "N/A" : String(value);
}
get displayVerificationSignature() {
return this.portable?.portable?.verification?.signature || "N/A";
}
handleOpenDossier() {
const url = this.portable?.portable?.links?.dossier_api_url;
if (url) {
window.open(url, "_blank");
}
}
handleVerifyDecision() {
const url = this.portable?.portable?.links?.verification_page_url;
if (url) {
window.open(url, "_blank");
}
}
}
Starter permission set for wrapper rollout. This gives the integration user the Apex and LWC access needed for the first pilot install.
<?xml version="1.0" encoding="UTF-8"?>
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<classAccesses>
<apexClass>DecionisActionGate</apexClass>
<enabled>true</enabled>
</classAccesses>
<classAccesses>
<apexClass>DecionisDecisionDossierController</apexClass>
<enabled>true</enabled>
</classAccesses>
<classAccesses>
<apexClass>DecionisGatewayClient</apexClass>
<enabled>true</enabled>
</classAccesses>
<fieldPermissions>
<editable>true</editable>
<field>Case.Decionis_Confidence__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Case.Decionis_Dossier_Id__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Case.Decionis_Outcome__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Case.Decionis_Policy_Version__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Case.Decionis_Verify_URL__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Contract.Decionis_Confidence__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Contract.Decionis_Dossier_Id__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Contract.Decionis_Outcome__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Contract.Decionis_Policy_Version__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Contract.Decionis_Verify_URL__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Opportunity.Decionis_Confidence__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Opportunity.Decionis_Dossier_Id__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Opportunity.Decionis_Outcome__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Opportunity.Decionis_Policy_Version__c</field>
<readable>true</readable>
</fieldPermissions>
<fieldPermissions>
<editable>true</editable>
<field>Opportunity.Decionis_Verify_URL__c</field>
<readable>true</readable>
</fieldPermissions>
<label>Decionis Governance Starter</label>
<objectPermissions>
<allowCreate>true</allowCreate>
<allowDelete>false</allowDelete>
<allowEdit>false</allowEdit>
<allowRead>true</allowRead>
<modifyAllRecords>false</modifyAllRecords>
<object>Decionis_Decision__e</object>
<viewAllRecords>false</viewAllRecords>
</objectPermissions>
</PermissionSet>
POST /v1/orgs/:orgId/salesforce/action-gateThin Salesforce wrapper over the universal Action Gate. Returns execute / escalate / delay plus the Decision Dossier component payload.
GET /v1/orgs/:orgId/decision-dossiers/:dossierId/portableCanonical artifact with human-readable lines, machine-readable fields, integrity, verification, distribution history, and channel-ready share-kit templates for Slack, Teams, Jira, email, and record surfaces.