Skip to content
Reference

Business Process contracts

Business Process start, work-action, user-task form, template, and decision-result SDK contracts and lifecycle rules.

Business Process Contracts

Publish the descriptors your Process needs, then register the runtime handler for each executable Work Action. The minimal declaration below makes an action discoverable; it does not install a handler. Use the runtime-registration section before executing the action, and preserve exact correlation identities for durable messages.

Signature

One static descriptor contribution exposes an App to the Process runtime. It returns an immutable set; Core selects the typed Process descriptors it needs:

Code example
PHP
use Nexia\AppDescriptors\Contracts\AppDescriptorContribution;
use Nexia\AppDescriptors\AppDescriptorSet;
use Nexia\AppDescriptors\ProcessWorkActionDescriptor;

appDescriptors() is evaluated during catalog assembly — before any tenant or actor exists. A set may contain ProcessStartBindingDescriptor, ProcessWorkActionDescriptor, ProcessUserTaskFormDescriptor, ProcessTemplateDescriptor, and DecisionResultTemplateDescriptor alongside the App's Approval or Signature descriptors.

Minimal example

Constructor-valid declaration example with illustrative Quality keys. It does not presume those Quality business Resources exist in your checkout; use your own registered Resource and the matching runtime integration below.

Code example
PHP
<?php

declare(strict_types=1);

namespace Amuzcorp\Nexia\Quality\Descriptors;

use Nexia\AppDescriptors\Contracts\AppDescriptorContribution;
use Nexia\AppDescriptors\AppDescriptorSet;
use Nexia\AppDescriptors\ProcessWorkActionDescriptor;

final class QualityProcessActions implements AppDescriptorContribution
{
    public static function appDescriptors(): AppDescriptorSet
    {
        return AppDescriptorSet::of(
            new ProcessWorkActionDescriptor(
                kind: 'serviceTask',
                appKey: 'quality',
                actionKey: 'inspection_request.finalize',
                labelKey: 'quality.process.inspection_request.finalize.label',
                topic: 'quality.inspection_request.finalize',
            ),
        );
    }
}

Parameters: ProcessWorkActionDescriptor

ParameterTypeDefaultBehavior
kindstring—serviceTask, sendTask, or receiveTask
appKeystring—Owning App; non-blank
actionKeystring—Stable identifier within the App, e.g. leave_request.finalize
labelKeystring—Label shown in the BPMN element picker
topicstring—Worker topic for serviceTask; message topic for sendTask / receiveTask
versionstring1.0Descriptor revision
payloadSchemaarray[]Per-key validation and authoring map
allowedBindingSourcesarray[]Where a process author may bind each input from
inputContractarray[]Declared inputs
approvalTaskProcessApprovalTaskConfiguration, nullnullMarks one serviceTask as a combined Approval submit-and-wait operation
outputContractarray[]Stable action outputs available through Activity IO
statusDescriptorStatusActiveActive, Deprecated, Removed

kind is validated against a closed set:

ProcessWorkActionDescriptor [quality.inspection.finalize] kind [userTask] must be one of: serviceTask, sendTask, receiveTask
ProcessWorkActionDescriptor app_key must be a non-empty string.

userTask is absent deliberately — human tasks are ProcessUserTaskFormDescriptor.

Choose kind by interaction shape:

KindMeanstopic is
serviceTaskThe process calls your App and waitsA worker topic your App consumes
sendTaskThe process emits a messageA message topic
receiveTaskThe process waits for a messageA message topic

ProcessStartBindingDescriptor

ParameterTypeDefaultBehavior
appKeystring—Owning App
bindingKeystring—Stable business intent within the App
definitionKeystring—App-owned Process definition key
processIdstring—BPMN process id inside the definition
resourceKeystring—App-owned Resource allowed to start the Process
startPermissionKeystring—App-owned Permission Core rechecks
labelKeystring—Binding label
versionstring1.0Descriptor revision
statusDescriptorStatusActiveLifecycle

The constructor rejects blank fields and requires definitionKey, resourceKey, and startPermissionKey to begin with appKey.. The effective lookup key is appKey.bindingKey.

Work Action runtime registration

AppDescriptorContribution::appDescriptors() publishes the catalog entry. A serviceTask implementation is registered separately through Nexia\Process\Contracts\ProcessWorkActionRegistrar:

Code example
PHP
use Nexia\Process\Contracts\ProcessWorkActionHandler;
use Nexia\Process\Contracts\ProcessWorkActionRegistrar;
use Amuzcorp\Nexia\Quality\Process\FinalizeInspectionRequest;

$registrar = app(ProcessWorkActionRegistrar::class);
$registrar->register(
    'quality',
    'inspection_request.finalize',
    static fn (): ProcessWorkActionHandler => app(FinalizeInspectionRequest::class),
);

Register this in your App service provider’s boot() method after implementing FinalizeInspectionRequest at the imported App-local path. It must implement ProcessWorkActionHandler::handle(ProcessWorkActionInvocation): ProcessWorkActionResult; the class name is illustrative, not a shipped Quality handler. Use Business Process for the full worker flow.

The handler receives ProcessWorkActionInvocation, including the frozen descriptor version, Process and external-task ids, idempotency key, restored Legal Entity and actor, canonical ResourceRef, actual Token elementId, and input. Save elementId with the immutable submission and exact Approval Case reference for later ProcessMessageDelivery; never infer it from an action name. Older SDK callers may omit it (null); the updated Core always supplies it. It returns ProcessWorkActionResult. ProcessWorkActionException carries a stable error code into Core-owned retry or incident state.

Approval Task metadata

An Approval Task uses one serviceTask; do not contribute separate “submit” and “wait” actions. Its approvalTask declares the App binding, durable outcome topic, outcome values, and the two payload keys that store tenant configuration. It declares no authoring preview target.

ProcessApprovalTaskConfiguration parameterTypeDefaultBehavior
bindingKeystring—App-owned Approval binding; normalized and non-blank
outcomeTopicstring—Durable message topic; normalized and non-blank
outcomeslist of stringapproved, rejected, recalled, cancelledNon-empty, unique, normalized outcome values
templatePayloadKeystringtemplate_keyPayload field that stores the selected live business template key
routePolicyPayloadKeystringapproval_route_policy_keyPayload field that stores the selected live route policy key
businessTemplatePresetKeystring, nullnullApp-declared default Approval business-template preset for authoring
routePolicyPresetKeystring, nullnullApp-declared default Approval route-policy preset for authoring
supportsLinePreparationboolfalsePermit human line selection on the same waiting task; the handler must consume the prepared line

The two optional preset keys select the App's intended recipes when the tenant has not materialized live Approval configuration yet. This keeps Core from inferring App domain policy from catalog order. The editor can instantiate the selected presets as Legal Entity-owned published template and active policy; the resulting live keys, not the preset keys, are written into the BPMN payload. Null preset fields are omitted from the serialized configuration.

The matching payloadSchema entries must use Core catalogs:

Code example
PHP
'template_key' => [
    'type' => 'string',
    'required' => true,
    'catalog_ref' => 'approval.business_template',
    'filter' => ['binding_key' => 'quality.inspection_request.submit'],
],
'approval_route_policy_key' => [
    'type' => 'string',
    'required' => true,
    'catalog_ref' => 'approval.route_policy',
    'filter' => ['status' => 'active'],
],

The example above requires an explicit active route. Actions supporting template-owned or human-selected lines instead make the route payload optional and declare supportsLinePreparation: true. The editor selects the published business template and uses its route when configured; otherwise the existing preparation task collects a line at execution. An explicitly selected route must still exist and be active. Publication checks template/binding/scope compatibility and any selected route, without resolving approvers for the author.

That check reports configuration usability only; it resolves no approval line. The runtime submitter is the process instance's starting actor, which no authoring input determines — a manual-start process may be started by any authorized user, an event-start process by whoever triggered the event. So the editor neither stands the author in for the submitter nor asks for a sample record; org-data gaps surface at submission, which fails closed, and in the instance's incident record. A directly placed Approval ServiceTask and a template-derived one must both map the declared approval_outcome through dataOutputAssociation and feed a following exclusive Gateway. App package tests can enforce the complete shape with Nexia\Testing\ApprovalProcessConformance::assert().

Structural conformance is only the first gate. The App handler must also create its own frozen submission evidence linked to the Approval Case, and the full Inbox consumer must apply that evidence before it calls ProcessRuntime::deliverMessage(). Exercise that real wiring with Nexia\Testing\ApprovalProcessBridgeConformance::assert(). Keep ApprovalOutcomeConsumerConformance::assert() as the separate delivery-status gate for Rejected, AlreadyConsumed, and Pending.

For a receiveTask, use Nexia\Testing\ReceiveTaskCorrelationConformance::assert() with the descriptor, BPMN structure, element id, required incoming keys, and the App's real event correlator. It checks both the Activity IO mapping and the message actually sent to the exact element and topic. A published wait is not complete until an authorized product action or connector can emit that message without asking a user to type an internal identifier.

ProcessUserTaskFormDescriptor

ParameterTypeDefaultBehavior
keystring—Stable form identity
appKeystring—Owning App
labelKeystring—Label in the task-form picker
renderingarray['mode' => 'core_schema']How the form renders
schemaarray['fields' => []]Input field schema
outputSchemaarray[]Values the form returns to the process
allowedResourceActionsarray[]Resource actions the form may invoke
versionstring1.0Descriptor revision
statusDescriptorStatusActiveLifecycle

The default rendering mode core_schema means the platform renders the form from schema, so a simple form needs no App frontend component at all. Populate schema.fields, then declare required outputs and wire the task’s Activity IO in BPMN.

allowedResourceActions is a whitelist, not documentation. A form may invoke only the actions it lists, and each is still authorized normally.

An outputSchema entry has type, optional variable, required, and nullable. For a required non-null string/enum result, add a non-empty unique enum, label_key, and an enum_labels translation key for every value. The BPMN editor then exposes the result as a localized Gateway choice and follows the UserTask's Activity IO target; the author does not enter an internal path or raw enum value.

ProcessTemplateDescriptor

ParameterTypeDefaultBehavior
keystring—Stable template identity; non-blank
versionstring—Required; non-blank
appKeystring, null—Required positionally, though nullable. When present, it is non-blank and equals the first segment of key; null marks a platform template
labelKeystring—Label in the template picker
categorystring—Grouping in the picker
structurearray—The BPMN structure
decisionsarray[]Legacy recommendation metadata; never persisted automatically
dependenciesarray[]Other artifacts the template requires
statusDescriptorStatusActiveLifecycle
descriptionKeystring, nullnullLonger description
nameKeysarray[]Per-locale names for the produced definition
ProcessTemplateDescriptor key must be a non-empty string.

DecisionResultTemplateDescriptor

ParameterTypeDefaultBehavior
keystring—Stable identity
versionstring—Required
labelKeystring—Label
fieldsarray—Result fields, as DecisionResultFieldDescriptor entries
statusDescriptorStatusActiveLifecycle
descriptionKeystring, nullnullCatalog description
decisionTablearray, nullnullOptional complete editable authoring seed
hitPolicystring, nullnullRequired supported policy when a complete seed is present

Declares an optional DMN authoring starter. A complete seed requires non-empty list-shaped inputs, outputs, and rules, and exactly one input because the guided editor seeds one input column. That input needs a non-empty id. When it declares source, the only kinds are event_payload and process_variable: an event source requires event_name and payload_key and its expression must be event_payload.{payload_key}; a process-variable expression must equal the input id. Every declared result field must occur in the outputs. Selection copies values into the normal editor only. It never creates a Decision Definition, chooses its persisted key, or becomes a runtime dependency.

A BusinessRuleTask placed manually and one copied from a Process template share the same creation flow: save the incomplete BPMN draft, open DMN in a separate work tab, explicitly save DMN, return and save the linked BPMN draft, publish DMN, then publish BPMN.

Version pinning

Version behavior follows the durable object that stores it. Definitions record source_template_key and source_template_version on default installation or when a save prepares a matching-key template from source_template_keys. Later revisions retain that provenance. Selection alone persists nothing; provenance does not make a newer template replace a saved definition or a running instance. A Work Action in the definition resolves by {app, action_key}. When Core creates an external task, however, it freezes the resolved App, action, version, and topic; execution refuses the task if the active descriptor no longer matches that tuple.

Consequences:

You doEffect on running processes
Change a descriptor's behavior at the same versionRunning definitions may drift — do not do this
Publish a new Work Action versionFuture tasks resolve the new descriptor; an existing external task retains its frozen tuple and fails closed if it no longer matches
Set status: DeprecatedNo new selection; existing definitions keep resolving
Set status: RemovedResolution stops; definitions referencing it break
Delete the descriptorSame as Removed, with no audit trail

Treat a published version as immutable. Behavior changes get a new version.

Resolution gates

A descriptor is selectable during authoring, and resolvable at execution, only when all of these hold:

GateRequirement
DiscoveryClass is in a declared contribution location and implements the interface
InstallationThe owning App is installed, active, and initialized for the tenant
Lifecyclestatus permits selection
AuthorizationCore rechecks a start binding's startPermissionKey. Work Actions restore the initiating actor, and the handler authorizes each protected operation it performs

Output or return: Process runtime access

To start an App-bound Process, resolve Nexia\Process\Contracts\ProcessStarter and call start(BoundProcessStart). The request carries the full binding key, Legal Entity, actor, canonical ResourceRef, variables, and an idempotency key. Core re-resolves authority and returns a read-only ProcessInstanceSnapshot; an exact retry returns the same snapshot, while a changed payload under the same key is rejected.

When an App must observe or correlate a running Process, resolve Nexia\Process\Contracts\ProcessRuntime:

MethodContract
hasPublishedEventStart($legalEntityKey, $eventName)Returns whether an executable published definition currently offers that event start in the Legal Entity
findInstance($publicId)Returns a read-only ProcessInstanceSnapshot, or null
correlateReceiveTask($correlation)Correlates one exact receive task and returns whether it matched
deliverMessage($delivery)Durably records and correlates one idempotent message to an exact waiting activity

The snapshot exposes only publicId, legalEntityKey, status, optional businessKey, and optional canonical ResourceRef. A ReceiveTaskCorrelation must carry the Legal Entity key, instance public id, optional business key, canonical Resource reference, BPMN element id, topic, payload, and optional required instance status. Its identifiers must be non-blank and already normalized.

ProcessMessageDelivery additionally carries a stable message identity and an optional external reference such as an Approval Case id. Build every new identity with ProcessMessageIdentity::forEvent($appKey, $consumerKey, $eventIdentity); its wire form is app.consumer:event-identity and the complete value is limited to 190 bytes. A matching delivery is consumed once, an early delivery remains pending, a duplicate returns AlreadyConsumed, and mismatched correlation is rejected without advancing the token. A rejected message remains immutable evidence: repair the linkage and revalidate that stored message, or emit a new correcting event when the evidence itself was wrong.

Do not guess the businessKey for a lifecycle-event start. Core's event-start derivation is private runtime behavior. Send the exact key only when your App started the instance and persisted the key it supplied; otherwise send null unless an SDK result gave you an authoritative value.

For an automatic lifecycle start, declare a public Resource lifecycle event, select it on the BPMN StartEvent, and publish the event through EventPublisher in the same transaction as the App state change; see Handle events and jobs for the EventDraft call. Core consumes the event and creates the instance. Before a one-shot transition, an App may use hasPublishedEventStart() as a non-mutating availability guard so it does not commit a state change that no published process can consume. The method neither publishes an event nor starts an instance.

To start the Process bound to an App intent, resolve Nexia\Process\Contracts\ProcessStarter and pass BoundProcessStart. Supply the binding key, Legal Entity, actor, canonical ResourceRef, variables, and a normalized idempotency key. Core selects the published definition; the App does not import a Core definition or instance model. This is an explicit App command capability, not a separate BPMN StartEvent mode; lifecycle automation still uses the Event start described above.

A registered App worker returns ProcessWorkActionResult. For a submitted Approval that has not reached a terminal outcome, return ProcessWorkActionResult::waitingForApproval(...). Core completes the external task but parks the same BPMN token as awaiting_approval; instance detail shows “Submitted · Awaiting approval”, the submission time, and the Approval link. After the durable outcome is consumed, the completed step keeps that link and shows the localized outcome plus completion time. The generic detail does not show the worker payload, catalog keys, message identity, topic, or broker envelope.

The terminal message payload is materialized only through the same task's Activity IO. Declare a closed localized approval_outcome, map it to a process variable with dataOutputAssociation, and make the next Gateway read that target. Test both an approved route and the default rejected/non-approved route through their terminal nodes; submission success alone is not an end-to-end Approval test.

The host owns Process persistence, locking, BPMN validation, durable message storage, and token advancement. An App supplies exact evidence; it never updates a host Process model or advances a token directly.

Approval configuration presets

An App can offer editable starting points by placing ApprovalBusinessTemplatePresetDescriptor and ApprovalRoutePolicyPresetDescriptor in its AppDescriptorSet. Presets are source descriptors, not live tenant configuration. An authorized Process author materializes a selected pair; Core creates and publishes/activates Legal Entity-owned records idempotently. The actor must separately hold Approval template create/publish and route-policy create/activate permissions. Process definition authority never implies Approval administration authority.

Errors

Message or symptomCauseResolution
kind [X] must be one of: serviceTask, sendTask, receiveTaskInvalid kindUse a supported kind, or the user-task form family
app_key must be a non-empty string.Blank appKeySupply the App key
ProcessStartBindingDescriptor X must be non-empty.Blank binding fieldSupply every stable binding identifier
Process start binding resource_key must be owned by app_key.Cross-App Resource bindingBind only a Resource owned by the contributing App
start_permission_deniedCurrent actor lacks the binding PermissionGrant the exact App-owned Permission in the current Legal Entity
ProcessTemplateDescriptor key must be a non-empty string.Blank keySupply a stable key
Action absent from the BPMN pickerDiscovery, installation, or lifecycle gateRun nexia-apps:doctor-package-app, then check install state and status
Published process fails at a taskDescriptor Removed, or the App deactivatedRestore the descriptor or reactivate the App
Label shows as a generic business termMissing locale entryAdd the key to resources/lang/{locale}.json; internal keys are intentionally hidden
Process message identity must use the namespaced form…Raw or unnamespaced event idBuild it with ProcessMessageIdentity::forEvent()
Receive-task correlation identifiers must be non-blank and normalized.Blank or whitespace-padded instance, element, topic, or business keyPass the exact normalized routing identifiers

Real usage

packages/people/src/Descriptors/PeopleOnboardingProcessDescriptors.php contributes a real start binding, Process template, user-task forms, and Work Actions. packages/people/src/PeopleCoreServiceProvider.php registers the service-action handlers through ProcessWorkActionRegistrar.

People resolves ProcessStarter for onboarding, while Grants and PSA resolve ProcessRuntime to inspect current instances and correlate Approval outcomes to exact waiting receive tasks. No package imports or updates a host Process model.

Resource targets and human input

Declare resourceInputs on a work action for accepted Resource keys, role, requiredness and single/list cardinality. targetResourceInput selects a single reference as the operation target. Core resolves current scope, App availability and actor authority at execution; a reference is not authorization. The invocation retains the originating subject separately so downstream targets cannot replace the Process correlation identity.

Use existing Activity IO mappings to bind output references to later operations. Human forms receive mapped inputs as frozen initial values. nexia:userTaskForm.editableFields optionally limits which declared schema fields may change; omitted means all are editable. Runtime rejects changes to fixed fields and still checks required inputs. The editor can insert a pure input form before an operation; forms whose own submission executes business work must not add a duplicate execution task.

Templates explicitly declaring installByDefault use the existing deployment lifecycle for eligible entities. Installation is idempotent, retains upstream provenance and company revisions, and does not restore a company-stopped default. Catalog readiness distinguishes missing dependencies/configuration from active or stopped procedures. Unknown assignees and missing approval configuration are not invented during provisioning. A newer supplied template is shown as an available update; it does not replace running definitions automatically.

Payload schema fields marked deprecated: true remain valid in existing definitions, but the authoring catalog omits them. Use this for retired configuration inputs whose legacy values must survive; do not remove their runtime interpretation until existing definitions have migrated.

Source of truth: docs/developers/content/en/app-sdk/business-process-contracts.md