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:
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.
<?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
| Parameter | Type | Default | Behavior |
|---|---|---|---|
kind | string | — | serviceTask, sendTask, or receiveTask |
appKey | string | — | Owning App; non-blank |
actionKey | string | — | Stable identifier within the App, e.g. leave_request.finalize |
labelKey | string | — | Label shown in the BPMN element picker |
topic | string | — | Worker topic for serviceTask; message topic for sendTask / receiveTask |
version | string | 1.0 | Descriptor revision |
payloadSchema | array | [] | Per-key validation and authoring map |
allowedBindingSources | array | [] | Where a process author may bind each input from |
inputContract | array | [] | Declared inputs |
approvalTask | ProcessApprovalTaskConfiguration, null | null | Marks one serviceTask as a combined Approval submit-and-wait operation |
outputContract | array | [] | Stable action outputs available through Activity IO |
status | DescriptorStatus | Active | Active, 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:
| Kind | Means | topic is |
|---|---|---|
serviceTask | The process calls your App and waits | A worker topic your App consumes |
sendTask | The process emits a message | A message topic |
receiveTask | The process waits for a message | A message topic |
ProcessStartBindingDescriptor
| Parameter | Type | Default | Behavior |
|---|---|---|---|
appKey | string | — | Owning App |
bindingKey | string | — | Stable business intent within the App |
definitionKey | string | — | App-owned Process definition key |
processId | string | — | BPMN process id inside the definition |
resourceKey | string | — | App-owned Resource allowed to start the Process |
startPermissionKey | string | — | App-owned Permission Core rechecks |
labelKey | string | — | Binding label |
version | string | 1.0 | Descriptor revision |
status | DescriptorStatus | Active | Lifecycle |
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:
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 parameter | Type | Default | Behavior |
|---|---|---|---|
bindingKey | string | — | App-owned Approval binding; normalized and non-blank |
outcomeTopic | string | — | Durable message topic; normalized and non-blank |
outcomes | list of string | approved, rejected, recalled, cancelled | Non-empty, unique, normalized outcome values |
templatePayloadKey | string | template_key | Payload field that stores the selected live business template key |
routePolicyPayloadKey | string | approval_route_policy_key | Payload field that stores the selected live route policy key |
businessTemplatePresetKey | string, null | null | App-declared default Approval business-template preset for authoring |
routePolicyPresetKey | string, null | null | App-declared default Approval route-policy preset for authoring |
supportsLinePreparation | bool | false | Permit 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:
'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
| Parameter | Type | Default | Behavior |
|---|---|---|---|
key | string | — | Stable form identity |
appKey | string | — | Owning App |
labelKey | string | — | Label in the task-form picker |
rendering | array | ['mode' => 'core_schema'] | How the form renders |
schema | array | ['fields' => []] | Input field schema |
outputSchema | array | [] | Values the form returns to the process |
allowedResourceActions | array | [] | Resource actions the form may invoke |
version | string | 1.0 | Descriptor revision |
status | DescriptorStatus | Active | Lifecycle |
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
| Parameter | Type | Default | Behavior |
|---|---|---|---|
key | string | — | Stable template identity; non-blank |
version | string | — | Required; non-blank |
appKey | string, null | — | Required positionally, though nullable. When present, it is non-blank and equals the first segment of key; null marks a platform template |
labelKey | string | — | Label in the template picker |
category | string | — | Grouping in the picker |
structure | array | — | The BPMN structure |
decisions | array | [] | Legacy recommendation metadata; never persisted automatically |
dependencies | array | [] | Other artifacts the template requires |
status | DescriptorStatus | Active | Lifecycle |
descriptionKey | string, null | null | Longer description |
nameKeys | array | [] | Per-locale names for the produced definition |
ProcessTemplateDescriptor key must be a non-empty string.
DecisionResultTemplateDescriptor
| Parameter | Type | Default | Behavior |
|---|---|---|---|
key | string | — | Stable identity |
version | string | — | Required |
labelKey | string | — | Label |
fields | array | — | Result fields, as DecisionResultFieldDescriptor entries |
status | DescriptorStatus | Active | Lifecycle |
descriptionKey | string, null | null | Catalog description |
decisionTable | array, null | null | Optional complete editable authoring seed |
hitPolicy | string, null | null | Required 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 do | Effect on running processes |
|---|---|
| Change a descriptor's behavior at the same version | Running definitions may drift — do not do this |
| Publish a new Work Action version | Future tasks resolve the new descriptor; an existing external task retains its frozen tuple and fails closed if it no longer matches |
Set status: Deprecated | No new selection; existing definitions keep resolving |
Set status: Removed | Resolution stops; definitions referencing it break |
| Delete the descriptor | Same 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:
| Gate | Requirement |
|---|---|
| Discovery | Class is in a declared contribution location and implements the interface |
| Installation | The owning App is installed, active, and initialized for the tenant |
| Lifecycle | status permits selection |
| Authorization | Core 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:
| Method | Contract |
|---|---|
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 symptom | Cause | Resolution |
|---|---|---|
kind [X] must be one of: serviceTask, sendTask, receiveTask | Invalid kind | Use a supported kind, or the user-task form family |
app_key must be a non-empty string. | Blank appKey | Supply the App key |
ProcessStartBindingDescriptor X must be non-empty. | Blank binding field | Supply every stable binding identifier |
Process start binding resource_key must be owned by app_key. | Cross-App Resource binding | Bind only a Resource owned by the contributing App |
start_permission_denied | Current actor lacks the binding Permission | Grant the exact App-owned Permission in the current Legal Entity |
ProcessTemplateDescriptor key must be a non-empty string. | Blank key | Supply a stable key |
| Action absent from the BPMN picker | Discovery, installation, or lifecycle gate | Run nexia-apps:doctor-package-app, then check install state and status |
| Published process fails at a task | Descriptor Removed, or the App deactivated | Restore the descriptor or reactivate the App |
| Label shows as a generic business term | Missing locale entry | Add the key to resources/lang/{locale}.json; internal keys are intentionally hidden |
Process message identity must use the namespaced form… | Raw or unnamespaced event id | Build it with ProcessMessageIdentity::forEvent() |
Receive-task correlation identifiers must be non-blank and normalized. | Blank or whitespace-padded instance, element, topic, or business key | Pass 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.
Related
- Business Process — choosing between these families
- Electronic Approval contracts — the Approval binding family
- Extension Model — descriptor lifecycle as a concept
- Fix Business Process and Electronic Approval — diagnosing an unavailable descriptor
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.