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, coherent option sources). Each FormFieldDefinition entry uses key, kind, label, placeholder, defaultValue, isRequired, options, optionSource, 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.

Selection Options: Enumerated or Remote​

A selection field carries its options in exactly one of two shapes, never in both:

  • options ([]FieldOption) — the option list was enumerable at deploy, so the projection wrote it out. A static source is enumerated whether it was configured inline on the field or reached through a form-global ref.
  • optionSource (*FieldOptionSource) — the options could not be enumerated, so the projection emits the descriptor a consumer needs to fetch them itself. Only remote sources reach this shape.

A field with neither is free-form: nothing constrains its value.

The projection is post-dereference. A designer ref is resolved against the form-global sources before the field is written, so a consumer never has to chase a dataSourceId. A ref that points at nothing yields neither options nor optionSource — exactly like a field with no source at all.

FieldOptionSource is:

FieldTypeMeaning
kindOptionSourceKindclassifies the source; OptionSourceRemote (remote) is the only value emitted
request*RemoteOptionRequestthe operation returning the option records
mapping*RemoteOptionMappinghow to read a label and a value out of each record; nil means the defaults

The designer's own source union also has static and ref, but neither survives projection, so OptionSourceKind is deliberately narrower than it.

RemoteOptionRequest addresses the operation in the framework's own resource/action addressing: resource, action, version (empty means the default), and params. RemoteOptionMapping names the record keys — labelKey (default label), valueKey (default value), disabledKey, and descriptionKey; an empty entry falls back to its default.

The framework never resolves an option source. Form-data validation reads options only, and a select field with no options accepts any submitted value — so an optionSource constrains nothing on the server. It exists for consumers that must render a stored value as its label: a business list view showing a stored select value needs the label, not the raw code, and the static case was already covered by options.

Deploy validation covers option sources at the top level and inside a detail table alike: an optionSource whose kind is outside the vocabulary, or a remote one whose request is absent or names a blank resource / action, fails the deploy. Neither would fail anywhere else — the framework never resolves the source — so the version would deploy cleanly and then render as a raw value in every consumer that replayed it.

Detail-table columns carry their own: columns entries are full FormFieldDefinition values, so a select column inside a table field has the same options / optionSource pair a top-level field has.

Remote Request Parameters​

RemoteOptionRequest.Params is a map[string]DynamicParam, carried unevaluated. Each parameter is one of two kinds:

DynamicParamKindWire valuePayloadMeaning
DynamicParamLiteralliteralvaluea fixed value the designer typed, identical for every evaluation
DynamicParamExpressionexpressionsourcean expression the form runtime evaluates against the live form values before issuing the request — what makes a cascading select work

The backend holds no form values to evaluate an expression against, so it stores the source text and a consumer replaying the request owns that step. value is deliberately not omitempty: a literal false, 0, or "" is a value the designer chose, and dropping it would silently change the request.

Ask HasBoundParams() before translating a column. (*RemoteOptionRequest).HasBoundParams() reports whether any parameter is an expression. It is the question a consumer must ask before assuming one lookup covers a whole column:

  • false — the request resolves identically for every row, so one call builds a value-to-label map for the entire column.
  • true — the option set is per row (its parameters depend on that row's own form values), so a list view must either evaluate and issue the request per row, or leave that column untranslated.

The method is nil-safe: a nil receiver reports false.

Translating Stored Values in a Host​

Inject approval.FormSchemaParser — it resolves at root scope whenever vef.ApprovalModule is enabled — and parse the version's form schema:

package options

import (
"context"
"encoding/json"
"fmt"

"github.com/coldsmirk/vef-framework-go/approval"
)

// OptionLabelPlan says how a list view should turn each selection field's
// stored value into a display label.
type OptionLabelPlan struct {
// Static maps a field key to its value-to-label table, built from the
// options the definition already carries.
Static map[string]map[string]string
// Remote maps a field key to the request a consumer replays ONCE to build
// that table itself. Only fields whose request has no bound parameters.
Remote map[string]*approval.RemoteOptionRequest
// PerRow lists the field keys whose option set depends on the row's own
// form values, so one lookup cannot translate the whole column.
PerRow []string
}

type OptionLabelService struct {
parser approval.FormSchemaParser
}

func NewOptionLabelService(parser approval.FormSchemaParser) *OptionLabelService {
return &OptionLabelService{parser: parser}
}

func (s *OptionLabelService) Plan(ctx context.Context, schema json.RawMessage) (*OptionLabelPlan, error) {
fields, err := s.parser.ParseFormFields(ctx, schema)
if err != nil {
return nil, err
}

plan := &OptionLabelPlan{
Static: make(map[string]map[string]string),
Remote: make(map[string]*approval.RemoteOptionRequest),
}

for _, field := range fields {
switch {
case field.Options != nil:
// Enumerated at deploy: translate locally, no lookup at all.
labels := make(map[string]string, len(field.Options))
for _, option := range field.Options {
labels[fmt.Sprint(option.Value)] = option.Label
}

plan.Static[field.Key] = labels

case field.OptionSource != nil:
request := field.OptionSource.Request
if request.HasBoundParams() {
plan.PerRow = append(plan.PerRow, field.Key)

continue
}

plan.Remote[field.Key] = request

default:
// Free-form field: the stored value is already what to display.
}
}

return plan, nil
}

// labelKey and valueKey apply RemoteOptionMapping's defaults.
func labelKey(mapping *approval.RemoteOptionMapping) string {
if mapping == nil || mapping.LabelKey == "" {
return "label"
}

return mapping.LabelKey
}

func valueKey(mapping *approval.RemoteOptionMapping) string {
if mapping == nil || mapping.ValueKey == "" {
return "value"
}

return mapping.ValueKey
}

Values are compared by their string form (fmt.Sprint), which is what the framework's own select validation does, so 1 and "1" address the same option.

Parsing is not the only way in. The same flat list is persisted on apv_flow_version.form_fields and returned as formFields by the flow-version detail response, so a host holding that row reads FlowVersion.FormFields directly. Parse when all it has is the schema document — the instance-detail responses return the host document verbatim as formSchema and never the derived fields.

OptionSource.Request can be relied on for a version that deployed: the missing-operation check above rejects a remote source without one, so it is never nil on fields that survived deploy.

Upload Fields​

The built-in parser projects the designer's upload widget onto the upload field kind.

An upload field's columnType is inferred from its file count, not from maxLength: maxCount > 1 yields json (the value is an array of storage keys — the same shape checkbox-group carries), and anything else yields text (a single storage key). maxLength bounds a string's length, while an upload's bound is how many files it accepts, so reading it here would size the column off the wrong quantity. An explicit columnType on the field still wins, as it does for every kind.

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, FieldOption, FieldOptionSource, RemoteOptionRequest, RemoteOptionMapping, DynamicParam, FormSnapshot, ActionLog, UserInfo, and UrgeRecord (there is no structured FormDefinition wrapper — the host document is opaque, and FormFieldDefinition with the shapes it nests is the only framework form 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
OptionSourceKindremote
DynamicParamKindliteral, expression

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