Skip to main content

Flow Design

Node Types

Node KindConstantWire valueDescription
StartNodeStartstartEntry point of the workflow
ApprovalNodeApprovalapprovalRequires approval action from assignees
HandleNodeHandlehandleRequires processing/handling action
ConditionNodeConditionconditionBranches based on conditions
CCNodeCCccSends notifications to specified users
EndNodeEndendTerminal point of the workflow

Condition Branching

Condition nodes evaluate ConditionBranch entries in priority order. Each branch contains one or more ConditionGroup values: conditions inside a group are combined with AND logic, while multiple groups on the same branch are combined with OR logic.

ConditionField uses the structured Subject / Operator / Value fields. Operator is ConditionOperator; the exported constants are OperatorEquals, OperatorNotEquals, OperatorGreater, OperatorGreaterOrEq, OperatorLess, OperatorLessOrEq, OperatorIn, OperatorNotIn, OperatorContains, OperatorNotContains, OperatorStartsWith, OperatorEndsWith, OperatorIsEmpty, and OperatorIsNotEmpty. The built-in evaluator compares field conditions natively in Go — no expression templating, no injection surface — with operator semantics typed per field kind; an operator outside the vocabulary fails the evaluation with an error rather than silently evaluating to false.

ConditionExpression evaluates the raw Expression string. The evaluation environment exposes:

NameValue
formDatathe instance FormData as a map
applicantIdcurrent applicant ID
applicantDepartmentIdapplicant department ID, or "" when absent
globalshost-resolved Instance.Globals values exposed as top-level bindings

Expression conditions are evaluated through the framework's expression.Engine abstraction (currently backed by expr-lang), wired by DI — the same engine documented in Expression Engine.

Host applications can implement approval.InstanceGlobalsResolver to resolve global variables from the authenticated principal at instance start. The snapshot is persisted on Instance.Globals; clients cannot submit it in the start request. Field conditions resolve Subject against globals before formData, and expression conditions expose globals as top-level bindings while the built-in formData, applicantId, and applicantDepartmentId names win collisions.

Detail-Table Aggregation

A field condition may fold a detail table's rows instead of comparing a scalar subject. The condition stays structured — no string DSL: subject names the table field, aggregate picks the fold, and column names the numeric column to fold.

AggregateKindWire valuecolumnFolds
AggregateSumsumrequiredsum of the named numeric column
AggregateCountcountforbiddenrow count
AggregateAvgavgrequiredaverage of the named numeric column

AggregateKind.FoldsColumn() reports whether a kind reduces a column (sum / avg) rather than rows (count). Folding is pluggable through the approval.Aggregator interface:

type Aggregator interface {
// Kind returns the aggregate kind this implementation folds.
Kind() AggregateKind
// Fold reduces the extracted column values (or the row count) into the
// comparison operand. matchable=false means the aggregate has no defined
// value for the input — e.g. avg over zero rows — and the condition must
// not match, mirroring SQL NULL comparison semantics.
Fold(values []float64, rowCount int) (result float64, matchable bool)
}

Register a custom aggregator alongside the built-in sum / count / avg with vef.ProvideApprovalAggregator; the condition evaluator picks it up by its AggregateKind with no changes to existing code:

vef.Run(
vef.ApprovalModule,
vef.ProvideApprovalAggregator(func() approval.Aggregator { return myMedian{} }),
app.Module,
)

Approval Methods

When a node has multiple assignees:

MethodConstantWire valueBehavior
SequentialApprovalSequentialsequentialApprovers process one by one in order
ParallelApprovalParallelparallelApprovers process simultaneously

The enum type is ApprovalMethod.

Pass Rules

RuleConstantWire valueBehavior
AllPassAllallAll assignees must approve
AnyPassAnyanyAt least one approval passes
RatioPassRatioratioA percentage must approve

Custom pass-rule implementations use PassRuleStrategy, PassRuleContext, and return a PassRuleResult (PassRulePending, PassRulePassed, PassRuleRejected).

Assignee Types

KindConstantWire valueDescription
UserAssigneeUseruserSpecific users
RoleAssigneeRoleroleUsers with a role
DepartmentAssigneeDepartmentdepartmentLeaders of the configured departments
SelfAssigneeSelfselfThe applicant
SuperiorAssigneeSuperiorsuperiorDirect superior
Dept LeaderAssigneeDepartmentLeaderdepartment_leaderLeaders of the applicant's own department (single-level lookup)
Form FieldAssigneeFormFieldform_fieldDetermined by a form field value

The enum type is AssigneeKind. Dynamic assignee insertion uses AddAssigneeType: AddAssigneeBefore (before), AddAssigneeAfter (after), and AddAssigneeParallel (parallel).

Node Field Permissions

Task nodes (TaskNodeData, embedded by approval and handle nodes) and CC nodes (CCNodeData) carry a fieldPermissions map: form-field key → Permission. The vocabulary is:

ConstantWire valueMeaning for the node's participants
PermissionVisiblevisibleread-only
PermissionEditableeditablemay submit a new value
PermissionHiddenhiddennot shown
PermissionRequiredrequirededitable and must be provided

An absent key means visible. Deploy validation checks the map against the derived form fields: every key must reference a top-level form field, values must be in the enum, CC nodes may only use the visible / hidden subset, and a required permission is rejected on a node whose timeout action resolves to auto_pass (the timeout scanner's auto-pass finishes tasks without the required check).

The map is enforced on the write path: during task processing, submitted formData is merged only for fields whose fieldPermissions entry is editable or required, and the merged subset is re-validated against the field definitions. The required must-fill check applies to approve and handle decisions only — reject, transfer, and rollback stay exempt. A node with no fieldPermissions map grants no write access: submitted form data is dropped.

On the read side, my.get_instance_detail returns a viewer-scoped fieldPermissions projection: the framework max-merges the lattice (hidden < visible < editable < required) over the viewer's participation contexts (own task, CC delivery, applicant), grants write strength (editable / required) only from a pending task or a resubmittable applicant, and clamps every read-only context to visible. hidden values are stripped from the returned formData, and a viewer with no recognized context sees nothing — the resolution is fail-closed.

Designer Defaults

NodeData.ApplyTo resolves omitted designer fields to exported defaults so the runtime matches an untouched designer control:

ConstantValue
DefaultExecutionTypeExecutionManual
DefaultApprovalMethodApprovalParallel
DefaultPassRulePassAll
DefaultEmptyAssigneeActionEmptyAssigneeAutoPass
DefaultSameApplicantActionSameApplicantSelfApprove
DefaultConsecutiveApproverActionConsecutiveApproverNone
DefaultRollbackTypeRollbackPrevious
DefaultRollbackDataStrategyRollbackDataKeep
DefaultTimeoutActionTimeoutActionNone
DefaultCCTimingCCTimingAlways
DefaultHandleApprovalMethodApprovalSequential
DefaultHandlePassRulePassAny
DefaultUrgeCooldownMinutes30

Flow Definition (React Flow Compatible)

The FlowDefinition struct is compatible with React Flow's JSON format:

type FlowDefinition struct {
Nodes []NodeDefinition `json:"nodes"`
Edges []EdgeDefinition `json:"edges"`
}

Each NodeDefinition contains a Kind and typed Data that is parsed into the appropriate struct (StartNodeData, ApprovalNodeData, HandleNodeData, ConditionNodeData, CCNodeData, EndNodeData).

Flow JSON Wire Shape

deploy treats the flow definition as a full snapshot. NodeDefinition.ParseData chooses the typed data struct from kind; an unknown kind returns ErrUnknownNodeKind, and malformed node data is wrapped with ErrNodeDataUnmarshal.

TypeJSON fields
FlowDefinitionnodes, edges
NodeDefinitionid, kind, position, data; position contains x and y
EdgeDefinitionid, source, target, sourceHandle, data

sourceHandle is required only for edges leaving a condition node, where it must match a branch id. Non-condition outgoing edges must omit sourceHandle. EdgeDefinition.data is designer metadata stored in the version flowSchema; runtime routing is driven by source, target, and sourceHandle.

Node data fields are:

Node data typeJSON fields
BaseNodeDataname, description; embedded by every node data type
StartNodeDatabase fields only
EndNodeDatabase fields only
TaskNodeDataassignees, executionType, emptyAssigneeAction, fallbackUserIds, adminUserIds, isTransferAllowed, isOpinionRequired, timeoutHours, timeoutAction, timeoutNotifyBeforeHours, urgeCooldownMinutes, ccs, fieldPermissions
ApprovalNodeDatabase fields + TaskNodeData fields + approvalMethod, passRule, passRatio, sameApplicantAction, consecutiveApproverAction, rollbackType, rollbackDataStrategy, rollbackTargetKeys, isRollbackAllowed, isAddAssigneeAllowed, addAssigneeTypes, isRemoveAssigneeAllowed, isManualCcAllowed
HandleNodeDatabase fields + TaskNodeData fields; deploy always sets approvalMethod to sequential and passRule to any (handle nodes expose no such wire fields)
CCNodeDatabase fields + ccs, isReadConfirmRequired, fieldPermissions
ConditionNodeDatabase fields + branches

assignees entries use kind, ids, formField, and sortOrder. ccs entries use kind, ids, formField, and timing. During deployment, these embedded arrays are materialized into FlowNodeAssignee and FlowNodeCC records in addition to the FlowNode row.

Condition branches use id, label, conditionGroups, isDefault, and priority. Each conditionGroups entry contains conditions; each condition uses kind, subject, operator, value, and expression. A field condition may instead fold a detail table's rows: aggregate (sum / count / avg) evaluates over the table field named by subject, and column names the numeric column to fold (required for sum / avg, forbidden for count) — see Detail-Table Aggregation.

timeoutHours and timeoutNotifyBeforeHours are in hours. urgeCooldownMinutes is in minutes; values less than or equal to 0 use the runtime default of 30 minutes. rollbackTargetKeys is checked when rollbackType is specified; it contains node keys, not database node IDs. fieldPermissions semantics are described in Node Field Permissions.

Form Schema and Derived Fields

The form definition is split in two at deploy:

  • FlowVersion.FormSchema (formSchema) is the host-owned form-designer document, submitted at deploy as params.formSchema and stored / returned as semantically equal JSON — the jsonb column normalizes formatting and key order while numeric precision is preserved (json.RawMessage end to end). The framework never interprets it.
  • FlowVersion.FormFields (formFields) is the flat []FormFieldDefinition list derived from that document exactly once at deploy through the injected approval.FormSchemaParser, and is the only form shape the framework consumes — for form-data validation, storage-table DDL, aggregate checks, and field-permission resolution. Parser upgrades never affect already-deployed versions.
type FormSchemaParser interface {
ParseFormFields(ctx context.Context, schema json.RawMessage) ([]FormFieldDefinition, error)
}

The built-in parser understands the vef-framework-react form-editor document; hosts with their own designer replace it wholesale with vef.ProvideApprovalFormSchemaParser(constructor). A nil or empty schema yields no fields (a flow without a form); parser errors abort the deploy. The ctx carries the deploy request's deadline — a host parser that performs I/O must honor it.

The derived fields are validated at deploy (unique keys, known kinds, compilable patterns, coherent bounds, single-level tables). Each FormFieldDefinition entry uses key, kind, label, placeholder, defaultValue, isRequired, options, validation, props, sortOrder, columnType, scale, and columns. Each option uses label and value. columns defines the row shape of a table field (kind is table): each entry is itself a FormFieldDefinition and must not declare its own columns — detail tables are single-level. On the table field itself, validation.minLength / maxLength bound the row count and isRequired means at least one row.

validation supports minLength, maxLength, min, max, pattern, and message. Submitted formData is capped by vef.approval.form_data_max_bytes after JSON encoding (default 64 KiB via config.DefaultFormDataMaxBytes), even when the flow has no form schema. When a schema exists, extra form keys are rejected; required fields reject absent, null, blank-string, and empty-array values. input, textarea, and date fields must be strings and may use minLength, maxLength, and pattern. number fields accept numeric JSON values and may use min and max; a number field whose columnType is integer additionally rejects fractional values. select fields validate scalar or array values against options when options are present. upload fields accept a non-blank string, a non-empty []string, or a non-empty array of non-blank strings. validation.message is used as the custom error message for pattern mismatches; other validation failures use the module i18n messages.

Flow Validation

Initiator Rules

isAllInitiationAllowed and initiators are mutually exclusive: when isAllInitiationAllowed is true, submitting initiator rules returns ErrInitiatorsNotAllowed (code 40022). When restricted, at least one initiator rule with at least one ID is required — an empty rule set or a rule whose ids is empty returns ErrInitiatorsRequired (code 40023), because a rule that selects nobody leaves the flow just as unstartable as no rule at all.

Business Binding Schema

Flows use BindingMode to decide how form/instance data is associated with business data:

  • BindingStandalone (wire value standalone) — form data lives in approval tables (the default).
  • BindingBusiness (wire value business) — the flow is bound to an existing business row via BusinessBindingConfig.

Business-bound flows (BindingMode = business) are validated against the live database schema at save time. When the configured tableName does not exist, ErrBindingTableMissing(tableName) is returned (code 40018, ErrCodeBindingSchemaInvalid). When a configured column does not exist in the table, ErrBindingColumnMissing(columnName) is returned (same code). Both name the missing object so operators can fix the configuration without inspecting the schema themselves.

Flow Models and Designer Enums

Flow design and persistence models exposed by the public package include FlowCategory, Flow, FlowVersion, FlowNode, FlowEdge, FlowInitiator, FlowNodeAssignee, FlowNodeCC, FormFieldDefinition, FormSnapshot, ActionLog, UserInfo, and UrgeRecord (there is no structured FormDefinition wrapper — the host document is opaque and only FormFieldDefinition is a framework shape). Flow-version status uses VersionStatus: VersionDraft (draft), VersionPublished (published), and VersionArchived (archived).

Flow.Labels is host-owned selection metadata — validated at save time by the shared label rule and equality-filterable in list queries; see RPC Resources for the wire shape.

Additional flow-designer enums:

EnumWire values
InitiatorKinduser, role, department
ExecutionTypemanual, auto_pass, auto_reject
ConditionKindfield, expression
CCKinduser, role, department, form_field
CCTimingalways, on_approve, on_reject
FieldKindinput, textarea, select, number, date, upload, table
ColumnDataTypestring, text, integer, decimal, boolean, date, datetime, json
Permissionvisible, editable, hidden, required

Next: Instance Runtime for what happens after a designed flow starts running.