Skip to main content

Events & Integration

Event Publication

The approval module publishes its domain events through the framework's transactional outbox transport (see Event Bus). Every approval command writes the event record in the same transaction as the business mutation; the outbox relay then forwards them to the configured sink.

Subscribers must:

  1. Attach with event.WithGroup("...") because the route resolves to an at-least-once transport.
  2. Rely on the Inbox middleware for dedupe (it activates automatically when event.middleware.inbox = true and a transport advertises AtLeastOnce).

The standalone apv_event_outbox table and module-private EventOutboxStatus constants from earlier snapshots have been retired. Approval no longer carries a private outbox — it composes with the framework one.

Domain Event Types

All approval events implement DomainEvent; AllEventTypes() is the exhaustive exported registry of their topic strings. Instance and task events embed InstanceEventBase (instanceId, instanceNo, tenantId, title, flowId, flowCode, optional businessRef, applicant, occurredTime). Task events additionally embed TaskEventBase (taskId, nodeId, nodeName). Flow events embed FlowEventBase (flowId, tenantId, code, name, occurredTime). The tables below list fields beyond those bases.

Instance lifecycle:

Type constantTopicPayload / constructorPayload fields beyond common fieldsWhen
EventTypeInstanceCreatedapproval.instance.createdInstanceCreatedEvent, NewInstanceCreatedEventnonea new instance was started
EventTypeInstanceCompletedapproval.instance.completedInstanceCompletedEvent, NewInstanceCompletedEventfinalStatus, finishedAt, reason (set only when finalStatus is terminated)instance reached a terminal status
EventTypeInstanceWithdrawnapproval.instance.withdrawnInstanceWithdrawnEvent, NewInstanceWithdrawnEventoperator (UserInfo), reasonapplicant withdrew the instance
EventTypeInstanceRolledBackapproval.instance.rolled_backInstanceRolledBackEvent, NewInstanceRolledBackEventfromNodeId, fromNodeName, toNodeId, toNodeName, operator (UserInfo), opinioninstance was rolled back to a previous node
EventTypeInstanceReturnedapproval.instance.returnedInstanceReturnedEvent, NewInstanceReturnedEventfromNodeId, fromNodeName, toNodeId, toNodeName, operator (UserInfo), opinioninstance was returned to applicant
EventTypeInstanceResubmittedapproval.instance.resubmittedInstanceResubmittedEvent, NewInstanceResubmittedEventoperator (UserInfo)returned or withdrawn instance was resubmitted
EventTypeInstanceBindingFailedapproval.instance.binding_failedInstanceBindingFailedEvent, NewInstanceBindingFailedEventtrigger, status, businessTable, errorMessagean eventual business projection attempt failed after the desired state committed; the durable worker keeps retrying — this event is an operator notification, not the retry mechanism. Synchronous failures roll back the approval transaction and never emit it

Node lifecycle:

Type constantTopicPayload / constructorPayload fields beyond common fieldsWhen
EventTypeNodeAutoPassedapproval.node.auto_passedNodeAutoPassedEvent, NewNodeAutoPassedEventnodeId, nodeName, reasona node auto-passed because of auto-pass execution, empty-assignee handling, or same-applicant handling

Task lifecycle:

Type constantTopicPayload / constructorPayload fields beyond common fieldsWhen
EventTypeTaskCreatedapproval.task.createdTaskCreatedEvent, NewTaskCreatedEventassignee (UserInfo), deadlinea task was created; sequential follow-up tasks may start with deadline omitted while waiting
EventTypeTaskApprovedapproval.task.approvedTaskApprovedEvent, NewTaskApprovedEventoperator (UserInfo), opiniontask approved
EventTypeTaskHandledapproval.task.handledTaskHandledEvent, NewTaskHandledEventoperator (UserInfo), opinionhandle task completed
EventTypeTaskRejectedapproval.task.rejectedTaskRejectedEvent, NewTaskRejectedEventoperator (UserInfo), opiniontask rejected
EventTypeTaskCanceledapproval.task.canceledTaskCanceledEvent, NewTaskCanceledEventassignee (UserInfo), reasonengine canceled a task that no longer needs a decision
EventTypeTaskTransferredapproval.task.transferredTaskTransferredEvent, NewTaskTransferredEventfrom (UserInfo), to (UserInfo), reasontask transferred to another user
EventTypeTaskReassignedapproval.task.reassignedTaskReassignedEvent, NewTaskReassignedEventfrom (UserInfo), to (UserInfo), reasonadmin reassigned a task
EventTypeTaskTimedOutapproval.task.timed_outTaskTimedOutEvent, NewTaskTimedOutEventassignee (UserInfo), deadlinetimeout scanner fired the configured timeout action
EventTypeAssigneesAddedapproval.task.assignees_addedAssigneesAddedEvent, NewAssigneesAddedEventaddType, assignees ([]UserInfo)dynamic assignees added
EventTypeAssigneesRemovedapproval.task.assignees_removedAssigneesRemovedEvent, NewAssigneesRemovedEventassignees ([]UserInfo)dynamic assignees removed
EventTypeTaskDeadlineWarningapproval.task.deadline_warningTaskDeadlineWarningEvent, NewTaskDeadlineWarningEventassignee (UserInfo), deadline, hoursLeftpre-warning scanner flagged an approaching deadline
EventTypeTaskUrgedapproval.task.urgedTaskUrgedEvent, NewTaskUrgedEventurger (UserInfo), target (UserInfo), messageapplicant urged an assignee

CC + Flow:

Type constantTopicPayload / constructorPayload fields beyond common fieldsWhen
EventTypeCCNotifiedapproval.cc.notifiedCCNotifiedEvent, NewCCNotifiedEventnodeId, nodeName, recipients ([]UserInfo), isManuala CC node or manual CC action delivered notifications
EventTypeFlowCreatedapproval.flow.createdFlowCreatedEvent, NewFlowCreatedEventcategoryIdflow created
EventTypeFlowUpdatedapproval.flow.updatedFlowUpdatedEvent, NewFlowUpdatedEventnoneflow updated
EventTypeFlowDeployedapproval.flow.deployedFlowDeployedEvent, NewFlowDeployedEventversionId, versionflow version deployed
EventTypeFlowToggledapproval.flow.toggledFlowToggledEvent, NewFlowToggledEventisActiveflow active flag changed
EventTypeFlowPublishedapproval.flow.publishedFlowPublishedEvent, NewFlowPublishedEventversionIdflow version published

Caller Context and Multi-Tenant Safety

Resource and command handlers resolve a CallerContext per request that bundles the tenant authority of the call. Exactly one of TenantID, IsSuperAdmin, or IsSystemInternal must be set; a zero CallerContext is fail-closed (treated as unauthorized).

MemberMeaning
TenantIDthe tenant this caller acts within; all reads/writes get filtered by it
IsSuperAdminthe principal carries approval:super_admin; cross-tenant queries allowed
IsSystemInternalthe call originates from framework-internal code (scanners, listeners)

Cross-tenant attempts return approval.ErrCrossTenantAccess. IsSuperAdmin(p) reports whether a principal carries the override role. The override role itself is exposed as approval.SuperAdminRole ("approval:super_admin") for host wiring.

Lifecycle Extension Points

Extension pointPhaseFailure semantics
approval.InstanceLifecycleHook (FX group vef:approval:lifecycle_hooks)synchronous, inside the engine transaction — instance creation and every status transitionreturning an error rolls back the surrounding command
approval.BusinessRefProvidersynchronous, inside the start-instance transactionreturning an error rolls back instance creation
approval.BusinessRefResolversynchronous, at claim time inside the start-instance transactionreturning an error rolls back instance creation
approval.SubscribeInstance (event.SubscribeTyped wrapper)asynchronous, after the transaction commitsthe bus retries via the outbox relay; consumers must be idempotent
approval.BindCommand (instance event → CQRS command bridge)asynchronous, after the transaction commitsredelivery re-dispatches the command; the command handler must be idempotent

InstanceLifecycleHook

type InstanceLifecycleHook interface {
OnInstanceCreated(ctx context.Context, db orm.DB, instance *Instance) error
OnInstanceTransition(ctx context.Context, db orm.DB, instance *Instance, from, to InstanceStatus) error
}

v0.38 generalizes the hook: the former OnInstanceCompleted(instance, finalStatus) is replaced by OnInstanceTransition(instance, from, to), which runs inside the same transaction as every instance status transition — completion (to.IsFinal()), return, withdrawal, resubmission, termination. It runs after the engine-owned business projection has recorded (and, in synchronous mode, applied) the new state, so the hook observes the business table as the transition leaves it; instance.Status already carries to. Returning an error rolls back the whole transition.

Use lifecycle hooks for invariants that must hold inside the transaction (e.g. allocating a tightly-coupled business row). Use event subscriptions (or BindCommand, below) for everything else. Register hooks with vef.ProvideApprovalLifecycleHook(constructor) — the constructor must return approval.InstanceLifecycleHook, and multiple hooks compose via the vef:approval:lifecycle_hooks group. The invocation order across hooks is unspecified (FX value groups carry no ordering), so hooks must be mutually independent; any non-nil error stops the remaining hooks. approval.NewFilteredLifecycleHook(hook, filters...) wraps a hook with the same InstanceFilter vocabulary used by SubscribeInstance (below), so a hook can be scoped to specific flow codes or tenants without hand-written predicates.

BusinessRefProvider and BusinessRefResolver

type BusinessRefProvider interface {
OnInstanceCreated(ctx context.Context, db orm.DB, flow *Flow, instance *Instance) (businessRef string, err error)
}

type BusinessRefResolver interface {
ResolveRecordKey(ctx context.Context, flow *Flow, businessRef string) (BusinessRecordKey, error)
}

BusinessRefProvider supplies or allocates the opaque Instance.BusinessRef when Flow.BindingMode == BindingBusiness; inject it with vef.SupplyBusinessRefProvider. At instance start the engine resolves the ref into an approval.BusinessRecordKey — a map from every configured KeyColumns entry to its value — through BusinessRefResolver. The default resolver treats a single-column ref verbatim and decodes a multi-column ref from a JSON object; register a replacement with vef.SupplyBusinessRefResolver when BusinessRef uses another shape (business number, encoded tuple, …) or resolving the key requires a host lookup. A ref that cannot be resolved fails instance creation with ErrInvalidBusinessRef (code 40109); a business-bound flow started without a ref fails with ErrBusinessRefRequired (code 40107).

Business-State Projection

v0.38 replaces the per-trigger write-back (and its binding listener) with a durable desired-state projection. A business-bound flow configures one BusinessBinding document — approval.BusinessBindingConfig, stored as jsonb on apv_flow and snapshotted immutably onto each deployed apv_flow_version; runtime instances only ever read the version snapshot:

FieldJSONMeaning
TableNametableNamebusiness table receiving approval state
KeyColumnskeyColumnsrecord key columns; must exactly match a non-null primary or unique key on the table (verified against the live schema at save time — ErrBindingSchemaInvalid / ErrBindingKeyNotUnique)
StatusColumnstatusColumncolumn receiving the (mapped) instance status
InstanceIDColumninstanceIdColumnmandatory — the compare-and-set fence that stops a stale instance from overwriting state owned by a newer approval round
StartedAtColumnstartedAtColumnoptional; receives the instance start time
FinishedAtColumnfinishedAtColumnoptional; receives the finish time for final statuses, NULL otherwise
StatusMappingstatusMappingoptional InstanceStatus → host status value map; missing entries fall back to the raw status string (ErrBindingStatusMappingInvalid rejects unknown statuses and blank values)

Convergence works in three steps:

  1. Claim at start. Inside the start_instance transaction the engine resolves the record key and claims one durable projection row per physical target (apv_business_projection, unique per table + record key). A non-final instance already owning the target blocks the claim with ErrBindingTargetBusy (code 40108); a final owner is superseded, and the previously applied owner is retained as the business-table CAS fence until the new desired state is actually written. The instance links to its claim via Instance.BusinessProjectionID.
  2. Desired state per transition. Every instance status transition bumps the projection's DesiredRevision and records the full desired state (status, started-at, finished-at). The projection converges on the latest desired state — intermediate statuses may be skipped, which is exactly why side effects tied to a specific lifecycle moment belong in BindCommand or SubscribeInstance instead.
  3. Apply. vef.approval.business_binding.consistency selects when the business row is written:
    • synchronous (default) — inside the approval transaction; a write failure rolls the approval action back.
    • eventual — the approval action commits immediately; a background worker (scan_interval, default 10s; batch_size, default 100) claims due projections with FOR UPDATE SKIP LOCKED plus a lease and applies the latest revision, retrying failures with exponential backoff (1s doubling, capped at 1 hour). Each failed attempt publishes InstanceBindingFailedEvent as an operator notification — the durable projection row, not the event, drives the retry.

The applied UPDATE sets the configured columns WHERE <key columns match> [AND <instanceIdColumn> = <applied owner>], translating the status through StatusMapping. Operators inspect and force convergence through the admin operations find_business_projections / retry_business_projection (see RPC Resources), and get_metrics reports businessProjectionCounts and pendingBusinessProjections.

The engine owns this projection; it is configuration-driven and not replaced by an extension hook. Hosts add behavior around it with lifecycle hooks, event subscriptions, and command bridges.

Host Subscriptions: SubscribeInstance

func SubscribeInstance[T InstanceEvent](
bus event.Bus,
handler func(ctx context.Context, evt T, env event.Envelope) error,
opts ...InstanceSubscribeOption,
) (event.Unsubscribe, error)

SubscribeInstance is the declarative wrapper over event.SubscribeTyped for instance events (any event type embedding InstanceEventBase, e.g. InstanceCompletedEvent). Routing filters are data, not predicates: InstanceSubscribeOption accepts approval.ForFlows(codes...) / approval.ForTenants(ids...) (InstanceFilter values), each OR-ing within its own dimension and AND-ing across filters passed to the same call; events that fail a filter are acknowledged without invoking the handler. Business predicates (final status, form values) belong in the handler body, not in filters.

Since v0.38 the handler also receives the delivery event.Envelope: Envelope.ID is the Inbox dedupe key, stable across redeliveries, and therefore the key to build manual idempotency on when the route is at-least-once.

vef.Invoke(func(bus event.Bus) error {
_, err := approval.SubscribeInstance(bus,
func(ctx context.Context, evt *approval.InstanceCompletedEvent, env event.Envelope) error {
// idempotent side effect; env.ID is stable across redeliveries
return nil
},
approval.ForFlows("leave_request"),
approval.WithGroup("app:leave-writeback"),
)
return err
})

The consumer group defaults to one derived from the handler's method identity (vef:sub:<module-relative pkg>.<Type>.<method>, mirroring the vef:default:<uuid> shape used elsewhere). Anonymous handlers fail with ErrAnonymousSubscriberGroup unless approval.WithGroup(name) is given; a duplicate derived group registered twice in one process fails with ErrDerivedGroupConflict. Renaming or moving a handler changes its derived group (a new consumer group starts and the old one is orphaned) — pin the group with WithGroup before such refactors. approval.WithConcurrency(n) sets the per-subscription worker count.

Command Bridge: BindCommand

func BindCommand[E InstanceEvent, C cqrs.Action](
bus event.Bus,
commands cqrs.Bus,
mapper func(evt E, env event.Envelope) (cmd C, ok bool),
opts ...InstanceSubscribeOption,
) (event.Unsubscribe, error)

BindCommand (v0.38) subscribes to instance event E and dispatches the mapped command C through the host's CQRS bus — the declarative bridge from approval facts to host side effects. mapper is a pure translation: it shapes the command from the event plus its delivery envelope and reports relevance (ok=false acknowledges without dispatching). Business logic belongs in the command handler, which runs the host's full behavior pipeline (transaction, audit, validation); the handler's result is discarded — dispatch is fire-and-record.

vef.Invoke(func(bus event.Bus, commands cqrs.Bus) error {
_, err := approval.BindCommand(bus, commands,
func(evt *approval.InstanceCompletedEvent, env event.Envelope) (app.SettleOrderCmd, bool) {
if evt.FinalStatus != approval.InstanceApproved {
return app.SettleOrderCmd{}, false
}
return app.SettleOrderCmd{InstanceID: evt.InstanceID, DedupeKey: env.ID}, true
},
approval.ForFlows("order_settlement"),
)
return err
})

The consumer group defaults to the command type's identity (vef:cmd:<module-relative pkg>.<Type>) — renaming or moving the command deliberately re-keys the subscription, exactly like renaming a SubscribeInstance handler; pin with approval.WithGroup ahead of such refactors. Binding the same command type twice in one process fails with ErrDerivedGroupConflict. Two guards reject invalid bindings up front: ErrNonCommandAction (the bound action type is a query) and ErrUnnamedCommandType (anonymous struct or interface-typed C). Filters and WithConcurrency apply as in SubscribeInstance.

Delivery inherits the event route's semantics: on an at-least-once transport the command handler must be idempotent — copy Envelope.ID into the command when it needs its own dedupe key. Unlike the eventual business projection — which converges on the latest desired state and may skip intermediate statuses — every instance transition dispatches its own command, making BindCommand the lane for side effects tied to a specific lifecycle moment.

Business Identifier Validation

Flow.BindingMode == BindingBusiness flows carry SQL identifiers (BusinessBindingConfig.TableName, every KeyColumns entry, StatusColumn, InstanceIDColumn, and the optional timestamp columns) that the engine-owned projection interpolates directly into an UPDATE template. To prevent SQL injection, the framework whitelists identifiers against ^[A-Za-z_][A-Za-z0-9_]{0,62}$:

if err := approval.ValidateBusinessIdentifier(table); err != nil {
return err
}

Empty / whitespace-only strings pass — the caller decides whether absence itself is an error. Anything outside the whitelist returns approval.ErrInvalidBusinessIdentifier. Admin-side Flow CRUD should bubble this up so operators see meaningful errors.

Delegation

Users can delegate their approval authority to others:

type Delegation struct {
DelegatorID string // Who delegates
DelegateeID string // Who receives delegation
FlowCategoryID *string // Optional: limit to category
FlowID *string // Optional: limit to specific flow
StartTime timex.DateTime // Delegation start
EndTime timex.DateTime // Delegation end
IsActive bool
}

Supporting Public API Map

AreaPublic API
caller safetyCallerContext, SystemCaller, IsSuperAdmin, SuperAdminRole, ErrCrossTenantAccess
form dataFormData, NewFormData, FormSchemaParser, FormFieldDefinition, FormSnapshot, ValidationRule, StorageMode, StorageJSON, StorageTable, FieldKind, FieldInput, FieldNumber, FieldDate, FieldTextarea, FieldSelect, FieldUpload, FieldTable, FieldOption, ColumnDataType, ColumnString, ColumnText, ColumnInteger, ColumnDecimal, ColumnBoolean, ColumnDate, ColumnDatetime, ColumnJSON
table storageFormTable, FormTableColumn (FormTable.SourceFieldKey is "" for the main table, or the owning table field's key for a detail-table child projection)
flow modelsFlowCategory, Flow, FlowVersion, FlowNode, FlowEdge, FlowInitiator, FlowNodeAssignee, FlowNodeCC, VersionStatus, VersionDraft, VersionPublished, VersionArchived, ActionLog, OperatorInfo, UrgeRecord, DefaultTenantID
node designFlowDefinition, NodeDefinition, EdgeDefinition, Position, NodeData, BaseNodeData, StartNodeData, ApprovalNodeData, HandleNodeData, ConditionNodeData, CCNodeData, EndNodeData, ErrUnknownNodeKind, ErrNodeDataUnmarshal
conditionsConditionKind, ConditionField, ConditionExpression, Condition, ConditionGroup, ConditionBranch, EvaluationContext, ConditionEvaluator, InstanceGlobalsResolver, AggregateKind, AggregateSum, AggregateCount, AggregateAvg, Aggregator
initiators and assigneesInitiatorKind, InitiatorUser, InitiatorRole, InitiatorDepartment, AssigneeKind, AssigneeDefinition, AssigneeService, ResolvedAssignee, UserInfo, UserInfoResolver, RoleMembershipChecker, AddAssigneeType, AddAssigneeBefore, AddAssigneeAfter, AddAssigneeParallel
CCCCKind, CCUser, CCRole, CCDepartment, CCFormField, CCTiming, CCTimingAlways, CCTimingOnApprove, CCTimingOnReject, CCDefinition, CCRecord (CCRecord.VisitID scopes the record to one node traversal, mirroring Task.VisitID)
business binding & projectionBusinessBindingConfig, BusinessRecordKey, BusinessProjection (model, table apv_business_projection), BindingProjectionStatus (BindingProjectionPending / Processing / Applied / Failed), BindingTrigger, BindingTriggerStarted, BindingTriggerCompleted, BindingTriggerReturned, BindingTriggerWithdrawn, BindingTriggerResubmitted
instance subscriptionsSubscribeInstance, BindCommand, InstanceEvent, InstanceFilter, InstanceSubscribeOption, ForFlows, ForTenants, WithGroup, WithConcurrency, NewFilteredLifecycleHook, ErrAnonymousSubscriberGroup, ErrDerivedGroupConflict, ErrNonCommandAction, ErrUnnamedCommandType
node behaviorApprovalMethod, TaskNodeData, ExecutionType, ExecutionManual, ExecutionAutoPass, ExecutionAutoReject, ConsecutiveApproverAction, ConsecutiveApproverNone, ConsecutiveApproverAutoPass, SameApplicantAction, SameApplicantSelfApprove, SameApplicantAutoPass, SameApplicantTransferSuperior, Permission, PermissionVisible, PermissionEditable, PermissionRequired, PermissionHidden, DefaultExecutionType, DefaultApprovalMethod, DefaultPassRule, DefaultEmptyAssigneeAction, DefaultSameApplicantAction, DefaultConsecutiveApproverAction, DefaultRollbackType, DefaultRollbackDataStrategy, DefaultTimeoutAction, DefaultCCTiming, DefaultHandleApprovalMethod, DefaultHandlePassRule, DefaultUrgeCooldownMinutes
rollback and timeoutsRollbackType, RollbackNone, RollbackPrevious, RollbackStart, RollbackAny, RollbackSpecified, RollbackDataStrategy, RollbackDataClear, RollbackDataKeep, EmptyAssigneeAction, EmptyAssigneeAutoPass, EmptyAssigneeTransferAdmin, EmptyAssigneeTransferSuperior, EmptyAssigneeTransferApplicant, EmptyAssigneeTransferSpecified, TimeoutAction, TimeoutActionNone, TimeoutActionAutoPass, TimeoutActionAutoReject, TimeoutActionNotify, TimeoutActionTransferAdmin
action and status enumsActionType, InstanceStatus, TaskStatus, NodeKind, StorageMode, VersionStatus
pass rulesPassRule, PassRuleContext, PassRuleStrategy, PassRuleResult, PassRulePending, PassRulePassed, PassRuleRejected
progress viewsTimelineEntryKind, TimelineEntry, NodeVisitStatus, NodeProgressStatus, InstanceFlowGraph, FlowGraphNode, FlowGraphNodeData, FlowGraphEdge, NodeParticipant, Activity, ActivityUrge, CCRecipient
eventsall New...Event constructors, DomainEvent, InstanceEventBase, TaskEventBase, FlowEventBase, NewInstanceEventBase, NewTaskEventBase, NewFlowEventBase, PayloadOccurredAt, AllEventTypes, and the EventType... constants
extension interfacesInstanceLifecycleHook, BusinessRefProvider, BusinessRefResolver, InstanceNoGenerator, ConditionEvaluator, InstanceGlobalsResolver, PrincipalTenantResolver, PrincipalDepartmentResolver, RoleMembershipChecker
DI helpers (package vef)SupplyBusinessRefProvider, SupplyBusinessRefResolver, ProvideApprovalLifecycleHook, ProvideApprovalAggregator, ProvideApprovalFormSchemaParser
admin DTOspackage approval/admin: Instance, InstanceDetail, InstanceDetailInfo, Task, ActionLog, Metrics, BusinessProjection
user DTOspackage approval/my: PendingTask, CompletedTask, CCRecord, InitiatedInstance, AvailableFlow, InstanceDetail, InstanceInfo, PendingCounts

Next: back to the Overview for wiring and configuration, or RPC Resources for the API surface these events originate from.