Electronic Approval contracts
The binding, document schema, and slot widget an App must ship together to appear as an Electronic Approval business form.
Electronic Approval Contracts
To expose an App action in the Approval composer, publish its binding and document schema, then register the widget named by formWidgetKey. The binding, schema, and widget identities must match. Start with the declaration below; submission authority and frozen evidence are detailed later.
Signature
One descriptor contribution, usually on one class:
use Nexia\AppDescriptors\Contracts\AppDescriptorContribution;
use Nexia\AppDescriptors\AppDescriptorSet;
use Nexia\AppDescriptors\ApprovalDocumentSchema;
use Nexia\AppDescriptors\ApprovalFormBindingDescriptor;A binding's identity is derived, not passed:
{appKey}.{resourceKey}.{actionKey} → quality.inspection_decision.submit
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\ApprovalDocumentSchema;
use Nexia\AppDescriptors\ApprovalFormBindingDescriptor;
final class QualityApprovalDescriptors implements AppDescriptorContribution
{
public static function appDescriptors(): AppDescriptorSet
{
return AppDescriptorSet::of(
new ApprovalFormBindingDescriptor(
appKey: 'quality',
resourceKey: 'inspection_decision',
actionKey: 'submit',
labelKey: 'quality.approval.inspection_decision.submit.label',
entryModes: ['link'],
formWidgetKey: 'quality.inspection_decision.submit',
documentSchemaKey: 'quality.inspection_decision',
submitPermissionKey: 'quality.inspection_decision.submit',
),
new ApprovalDocumentSchema(
appKey: 'quality',
resourceKey: 'inspection_decision',
titleKey: 'quality.approval.inspection_decision.document.title',
sections: [[
'title_key' => 'quality.approval.inspection_decision.document.summary',
'fields' => [
['key' => 'proposed_decision', 'label_key' => 'quality.inspection_decision.proposed_decision.label'],
['key' => 'reason_text', 'label_key' => 'quality.inspection_decision.reason_text.label'],
],
]],
),
);
}
}The typed binding and schema remain separate values; only their discovery moved to the shared set. Approval business-template and route-policy presets use the same publication boundary.
Parameters: ApprovalFormBindingDescriptor
| Parameter | Type | Default | Behavior |
|---|---|---|---|
appKey | string | — | Owning App; non-blank |
resourceKey | string | — | Resource within the App; non-blank |
actionKey | string | — | The approvable action; non-blank |
labelKey | string | — | Label in the composer; non-blank |
entryModes | list of string | — | At least one of create, link |
formWidgetKey | string | — | Slot Widget key that renders the form; non-blank |
documentSchemaKey | string | — | Document schema submitted; non-blank |
version | string | 1.0 | Descriptor revision |
status | DescriptorStatus | Active | Active, Deprecated, Removed |
descriptionKey | string, null | null | Longer description |
approvedLabelKey | string, null | null | Outcome label when approved |
rejectedLabelKey | string, null | null | Outcome label when rejected |
recalledLabelKey | string, null | null | Outcome label when recalled |
cancelledLabelKey | string, null | null | Outcome label when cancelled |
submitPermissionKey | string, null | null | Permission required to submit |
ApprovalFormBindingDescriptor::ENTRY_MODES is ['create', 'link']:
| Mode | The record | Use when |
|---|---|---|
create | Created as part of submission | The approval is how the record comes into existence |
link | Already exists, attached to the case | The record is authored first, then submitted |
Declare both when either flow is valid. Use ['link'] when the record is authored before submission.
Validation
Every required field is validated in the constructor, with the derived id in the message:
ApprovalFormBindingDescriptor app_key must be a non-empty string.
ApprovalFormBindingDescriptor [quality] resource_key must be a non-empty string.
ApprovalFormBindingDescriptor [quality.inspection_decision] action_key must be a non-empty string.
ApprovalFormBindingDescriptor [quality.inspection_decision.submit] label_key must be a non-empty string.
ApprovalFormBindingDescriptor [quality.inspection_decision.submit] must declare at least one entry mode.
ApprovalFormBindingDescriptor [quality.inspection_decision.submit] entry mode [attach] must be one of: create, link
ApprovalFormBindingDescriptor [quality.inspection_decision.submit] form_widget_key must be a non-empty string.
ApprovalFormBindingDescriptor [quality.inspection_decision.submit] document_schema_key must be a non-empty string.
ApprovalDocumentSchema
| Parameter | Type | Default | Behavior |
|---|---|---|---|
appKey | string | — | Owning App |
resourceKey | string | — | Resource the document describes |
sections | array | — | Ordered document sections |
version | string | 1.0 | Schema revision |
status | DescriptorStatus | Active | Lifecycle |
titleKey | string, null | null | Document title key |
Each section is an array with a title key and a list of fields:
[
'title_key' => 'quality.approval.inspection_decision.document.summary',
'fields' => [
['key' => 'decision_revision', 'label_key' => 'quality.inspection_decision.decision_revision.label'],
],
]The schema declares what an approver reads, not what your App stores. Include the fields needed to make a decision; exclude internal bookkeeping.
The three pieces must match
A binding is not self-contained. Three artifacts join by key, and a mismatch in any one produces a binding the composer offers but cannot render:
| Artifact | Key | Must match |
|---|---|---|
ApprovalFormBindingDescriptor | formWidgetKey | A SlotWidgetDescriptor.key |
SlotWidgetDescriptor | slot | approval.composer.business_form |
SlotWidgetDescriptor | component | A registered frontend component |
ApprovalFormBindingDescriptor | documentSchemaKey | {appKey}.{resourceKey} of a schema |
Use the same resource/action identity to derive the binding and widget keys.
Add this value to the same AppDescriptorSet. Import SlotWidgetDescriptor at the top of the contributor file:
use Nexia\AppDescriptors\SlotWidgetDescriptor;
new SlotWidgetDescriptor(
key: 'quality.inspection_decision.submit',
version: '1.0',
slot: 'approval.composer.business_form',
component: 'quality.inspection_decision.submit',
slotApiVersion: 2,
permission: 'quality.inspection_decision.submit',
);slotApiVersion: 2 means the component consumes
ApprovalBusinessFormSlotPropsV2 from @nexia/sdk.
Authority
A binding routes a request. It does not authorize a write.
| The platform's Approval does | Your App must do |
|---|---|
| Route the case through its approval line | Validate the submitted payload |
| Record the outcome and audit trail | Enforce the Policy on the final record action |
| Render your widget and document | Reject an action the actor may not perform |
An approved case is an input to your App's decision, not a substitute for it.
submitPermissionKey gates submission; it does not gate the eventual write.
Do not build a parallel approval mechanism inside the App. A Resource that goes through electronic approval wires into the platform Approval as an business form.
Output or return: Frozen binding evidence
Submit through ApprovalHost::submitBound() with a
BoundApprovalSubmission. It makes the evidence that must not drift explicit:
Supporting files use one list contract throughout. Call
prepareSupportingAttachments() to obtain
list<ApprovalAttachmentEvidence>, then pass that list directly as the second
argument to submitBound(). ApprovalAttachmentEvidence contains the Media
UUID, checksum, scan verdict, and optional scan time. There is no prepared
attachments wrapper.
| Field group | Frozen evidence |
|---|---|
| Template | templateKey, templateVersion |
| Binding | bindingKey, bindingVersion |
| Resource | canonical ResourceRef, resourceVersion |
| Decision | documentValues, resolved lineDefinition, title and optional labels |
| Replay | optional idempotencyKey and idempotencyFingerprint |
The host verifies the selected template, binding version, canonical Resource
identity, and resolved line before creating the case. Later,
ApprovalHost::caseSummary() returns ApprovalCaseSummary with the frozen
binding app/resource/action fields and Resource version. It returns null for a
missing case or a legacy/non-canonical Resource reference; never invent a
partial reference from that absence.
For a tenant-authored Process that selects current live configuration, use
submitCurrentBound(CurrentBoundApprovalSubmission, $attachments). The host
resolves and atomically snapshots the current published template, active route
policy, and installed binding. Its CurrentBoundApprovalResult returns the case,
route, and exact template/binding versions that were frozen.
This is the automatic-policy opt-in submission path. submitBound() retains its
ordinary manual Approval semantics and never evaluates automatic-policy facts.
Optional policy automatic approval facts
Automatic approval is an opt-in Core policy for one binding. An App does not send an approved boolean or make a user action/signature. Register a provider only when the binding supports it:
use Nexia\Approval\Contracts\AutomaticApprovalFactProviderRegistrar;
/** @var AutomaticApprovalFactProviderRegistrar $registrar */
$registrar->register('quality.inspection_decision.submit', fn () => new QualityApprovalFacts($records));evaluate() runs while Core holds the submission transaction. It must lock and reread the current App record named by the ResourceRef, then return AutomaticApprovalEvaluation(resourceRef, resourceVersion, documentFingerprint, facts). Derive the fingerprint from the rendered snapshot, never from raw values or a non-canonical JSON encoding:
use Nexia\Support\CanonicalPayloadFingerprint;
$snapshot = $approvalHost->renderDocument('quality.inspection_decision', $documentValues)?->toArray();
$documentFingerprint = CanonicalPayloadFingerprint::sha256($snapshot);Facts are persisted scalar bool, int, or string values; use integer minor units or canonical decimal strings for money. Core rejects a changed ResourceRef, resource version, or canonical rendered-document fingerprint, evaluates only a configured active policy, and records immutable policy/version/facts/time/result evidence. A Legal Entity's active policy takes priority for that binding; Core uses the tenant catalog policy only when that Legal Entity has no active policy. The lifecycle permissions are separate: approval.automatic_policy.* manages a Legal Entity policy and approval.automatic_policy_catalog.* manages the tenant catalog.
Use a stable idempotency key for a retry of the same bound request. The host binds the key to the drafter and immutable request payload, including attachments; within the 24-hour idempotency retention window, a matching retry returns the original frozen case, route, template, and binding result even after the policy is retired. It is not an indefinite replay guarantee. A changed payload or another drafter is rejected. Missing or unmatched facts, unavailable providers, and strong-signature templates preserve the normal human route. Consumers may distinguish outcome_source (human or policy) and verify policy_key, policy_version, and document_fingerprint; a policy result is never a human signature.
Binding versus template
Frequently confused, and not interchangeable:
| Approval binding | Approval Form Template | |
|---|---|---|
| Lives in | Your package source | Tenant data |
| Owned by | Your App | An administrator |
| Purpose | The App-to-Approval runtime connection | A stored, selectable business form |
| Changes when | You ship a release | An administrator edits it |
A template may select a binding when published. The binding remains the installed App capability the platform resolves at submission time.
Errors
| Symptom | Cause | Resolution |
|---|---|---|
InvalidArgumentException at boot naming the binding id | Constructor validation | Read the message; it names the missing field |
| Binding never offered in the composer | Discovery, install, or status gate | nexia-apps:doctor-package-app, then check tenant install and status |
| Binding offered, composer renders nothing | formWidgetKey has no matching Slot Widget, or the component is unregistered | Align the keys, then open the composer — the doctor walks pageElements() only and never checks a widget component |
| Document is empty or mislabeled | documentSchemaKey does not match a contributed schema | Align {appKey}.{resourceKey} |
| Approver sees raw i18n keys | Missing locale entries | Add the keys to resources/lang/{locale}.json |
Use in your App
For a working App integration, publish the binding and document schema, register the matching composer widget key, and submit through the host capability. The Quality resource/action names above illustrate those identities; do not expect the old Quality descriptor files to exist.
Operation requirement settings
Opt a binding into supportsRequirementPolicy only after both its direct and formal submission paths enforce ApprovalRequirements::required(bindingKey, legalEntityKey, default, contextKey). Core exposes that operation in Approval → Administration → Operation approval settings. App settings link to /workbench/approvals/operation-requirements?app=<app-key>; do not create another editable requirement field.
An existing contributor may implement ApprovalRequirementContextProvider to list ApprovalRequirementContext values: a stable condition key, human label, and the existing App default. Core discovers it from the binding contribution. The App selects the context from trusted business data and passes it in requirementContextKey when submitting. Precedence is entity condition, entity general, company condition, company general, then App default. Store the effective decision with submission evidence; later settings changes must not alter an in-flight operation.
submitPermissionKey controls capability discovery. additionalSubmitPermissionKeys can declare alternative delegated capabilities. Any one must be granted in the selected scope; the App endpoint still authorizes the exact record and operation. A visible form never grants permission to mutate a Resource.
A published template may own route_policy_key. Changing it requires a template revision. A company template cannot select an entity-only route, and company routes cannot be shadowed by same-key entity routes. CurrentBoundApprovalSubmission accepts a manual lineDefinition without a route; its result route may therefore be null. A missing explicitly selected route remains an error.
For Process human line selection, opt the action into supportsLinePreparation and consume ProcessWorkActionInvocation.approvalLine. Core creates a preparation User Task on the same waiting token. Completing preparation validates the line and retries the same operation; it does not approve or advance it. The App submits and applies outcomes through its existing command and durable callback.
Common-data operations set requirementPolicyLegalEntityScoped: false and pass a null entity key to requirement lookup. They appear only in company settings. Use requirementPolicyContextsOnly: true when only contributed conditions may be changed: Core rejects a blanket override. These flags preserve existing domain scope rather than making every bound operation optional.
For business creation or Process execution, ApprovalIdempotencyKey(durable: true) retains the operation result beyond the normal 24-hour HTTP retry period. Use the same immutable actor/target/input fingerprint on retries. Ordinary keys retain their existing expiry; durable keys must remain available as business execution evidence.
Related
- Business Process — the end-to-end flow
- Add a Slot Widget — the widget half of the pair
- React components and hooks —
ApprovalBusinessFormSlotPropsV2and the composer registry - Fix Business Process and Electronic Approval — diagnosing each error above
The host retains the actual bound subject in ApprovalException.preparationResourceRef for Process line preparation; this internal reference is excluded from the public error payload. A child operation can therefore prepare approval without changing the Process origin.