Skip to content
Reference

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:

Code example
PHP
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.

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\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

ParameterTypeDefaultBehavior
appKeystring—Owning App; non-blank
resourceKeystring—Resource within the App; non-blank
actionKeystring—The approvable action; non-blank
labelKeystring—Label in the composer; non-blank
entryModeslist of string—At least one of create, link
formWidgetKeystring—Slot Widget key that renders the form; non-blank
documentSchemaKeystring—Document schema submitted; non-blank
versionstring1.0Descriptor revision
statusDescriptorStatusActiveActive, Deprecated, Removed
descriptionKeystring, nullnullLonger description
approvedLabelKeystring, nullnullOutcome label when approved
rejectedLabelKeystring, nullnullOutcome label when rejected
recalledLabelKeystring, nullnullOutcome label when recalled
cancelledLabelKeystring, nullnullOutcome label when cancelled
submitPermissionKeystring, nullnullPermission required to submit

ApprovalFormBindingDescriptor::ENTRY_MODES is ['create', 'link']:

ModeThe recordUse when
createCreated as part of submissionThe approval is how the record comes into existence
linkAlready exists, attached to the caseThe 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

ParameterTypeDefaultBehavior
appKeystring—Owning App
resourceKeystring—Resource the document describes
sectionsarray—Ordered document sections
versionstring1.0Schema revision
statusDescriptorStatusActiveLifecycle
titleKeystring, nullnullDocument title key

Each section is an array with a title key and a list of fields:

Code example
PHP
[
    '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:

ArtifactKeyMust match
ApprovalFormBindingDescriptorformWidgetKeyA SlotWidgetDescriptor.key
SlotWidgetDescriptorslotapproval.composer.business_form
SlotWidgetDescriptorcomponentA registered frontend component
ApprovalFormBindingDescriptordocumentSchemaKey{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:

Code example
PHP
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 doesYour App must do
Route the case through its approval lineValidate the submitted payload
Record the outcome and audit trailEnforce the Policy on the final record action
Render your widget and documentReject 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 groupFrozen evidence
TemplatetemplateKey, templateVersion
BindingbindingKey, bindingVersion
Resourcecanonical ResourceRef, resourceVersion
DecisiondocumentValues, resolved lineDefinition, title and optional labels
Replayoptional 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:

Code example
PHP
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:

Code example
PHP
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 bindingApproval Form Template
Lives inYour package sourceTenant data
Owned byYour AppAn administrator
PurposeThe App-to-Approval runtime connectionA stored, selectable business form
Changes whenYou ship a releaseAn 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

SymptomCauseResolution
InvalidArgumentException at boot naming the binding idConstructor validationRead the message; it names the missing field
Binding never offered in the composerDiscovery, install, or status gatenexia-apps:doctor-package-app, then check tenant install and status
Binding offered, composer renders nothingformWidgetKey has no matching Slot Widget, or the component is unregisteredAlign the keys, then open the composer — the doctor walks pageElements() only and never checks a widget component
Document is empty or mislabeleddocumentSchemaKey does not match a contributed schemaAlign {appKey}.{resourceKey}
Approver sees raw i18n keysMissing locale entriesAdd 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.

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.

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