Skip to content

Latest commit

 

History

History
790 lines (671 loc) · 29.8 KB

File metadata and controls

790 lines (671 loc) · 29.8 KB
title Flow Metadata
description Build visual automation with decision trees, data operations, HTTP requests, and screen interactions

Flow Metadata

A Flow is a visual automation that orchestrates business logic through connected nodes. Flows support decision branching, data operations (CRUD), HTTP integrations, script execution, user screens, and subflow composition.

Basic Structure

const approvalFlow = {
  name: 'order_approval',
  label: 'Order Approval Flow',
  type: 'record_change',
  version: 1,
  status: 'active',
  template: false,
  runAs: 'system',

  variables: [
    { name: 'order_amount', type: 'number', isInput: true, isOutput: false },
    { name: 'approved', type: 'boolean', isInput: false, isOutput: true },
  ],

  nodes: [
    { id: 'start', type: 'start', label: 'Start' },
    {
      id: 'check_amount',
      type: 'decision',
      label: 'Check Amount',
      config: {
        // decision expressions use {braces} around variables — see Expressions in flows
        conditions: [
          { label: 'High Value', expression: '{order_amount} > 10000' },
          { label: 'Standard', expression: '{order_amount} <= 10000' },
        ],
      },
    },
    {
      id: 'auto_approve',
      type: 'update_record',
      label: 'Auto Approve',
      config: { objectName: 'order', fields: { status: 'approved' } },
    },
    {
      id: 'request_approval',
      type: 'approval',
      label: 'Manager Approval',
      config: { approvers: [{ type: 'position', value: 'manager' }] },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],

  edges: [
    { id: 'e1', source: 'start', target: 'check_amount', type: 'default' },
    { id: 'e2', source: 'check_amount', target: 'auto_approve', type: 'default', condition: 'order_amount <= 10000' },
    { id: 'e3', source: 'check_amount', target: 'request_approval', type: 'default', condition: 'order_amount > 10000' },
    { id: 'e4', source: 'auto_approve', target: 'end', type: 'default' },
    { id: 'e5', source: 'request_approval', target: 'end', type: 'default' },
  ],
};

Flow Properties

Property Type Required Description
name string Machine name (snake_case)
label string Display label
description string optional Flow description
version number optional Version number (defaults to 1)
status enum optional 'draft', 'active', 'obsolete', 'invalid' (defaults to 'draft')
type FlowType Flow trigger type (see below)
template boolean optional Is this a reusable subflow template (defaults to false)
runAs enum optional 'system' or 'user' execution context (defaults to 'user')
variables FlowVariable[] optional Input/output variables
nodes FlowNode[] Flow nodes
edges FlowEdge[] Connections between nodes
errorHandling object optional Error handling strategy

Flow Types

Type Description Trigger
autolaunched Runs automatically without user interaction Invoked by other flows or API
record_change Triggered by record create/update/delete Data mutation events
schedule Runs on a schedule (cron) Time-based
screen Interactive flow with user screens User clicks a button
api Exposed as an API endpoint HTTP request

Nodes

Each node performs a specific action in the flow.

Node Types

Type Description
start Flow entry point
end Flow termination
decision Conditional branching (if/else)
assignment Set variable values
loop Structured iteration container — runs a body region once per item (ADR-0031)
parallel Structured parallel block — runs N branch regions concurrently, implicit join (ADR-0031)
try_catch Structured try/catch/retry error handling (ADR-0031)
create_record Create a new record
update_record Update existing records
delete_record Delete records
get_record Query records
http Make an HTTP API call
notify Send an outbound notification via the messaging service
script Run a custom script action (dispatched by config.actionType)
screen Display a user form/screen (durable pause)
wait Pause for a timer or named signal (durable pause; timers auto-resume)
subflow Invoke another flow — a pause inside the child suspends both runs as a linked chain
map Sequential multi-instance — run a per-item subflow for each element, one at a time (each may pause); batch approval (ADR-0039)
connector_action Execute an external connector action

connector_action dispatches against the runtime connector registry, which connector plugins populate. The three generic executorsrest, openapi, and mcp — double as provider factories: with them in your app's plugins:, a declarative connectors: entry that names a provider is materialized into a live, dispatchable connector at boot with no plugin code (ADR-0097). Scaffolded projects (npm create objectstack) ship all three executors by default — paired with the automation capability that performs the materialization — and the showcase wires them too. Brand connectors (Slack, …) are installed separately. One security note: a declarative mcp instance using a stdio transport spawns a local process from metadata and is therefore denied by default — the host opts in with new ConnectorMcpPlugin({ declarativeStdio: ['<trusted-command>'] }); http transports need no opt-in.

Node Structure

{
  id: 'unique_node_id',
  type: 'decision',
  label: 'Check Priority',
  config: { /* type-specific configuration */ },
  position: { x: 200, y: 100 },    // Canvas position (optional)
}
Property Type Required Description
id string Unique node identifier
type FlowNodeAction Node type
label string Display label
config object optional Type-specific configuration
connectorConfig object optional External connector settings
position { x, y } optional Visual position on canvas

Node Examples

Decision (branching):

{
  id: 'check_status',
  type: 'decision',
  label: 'Check Status',
  config: {
    conditions: [
      { label: 'Approved', expression: "{status} == 'approved'" },
      { label: 'Rejected', expression: "{status} == 'rejected'" },
    ],
  },
}

Create Record:

{
  id: 'create_task',
  type: 'create_record',
  label: 'Create Follow-up Task',
  config: {
    objectName: 'task',
    fields: {
      title: 'Follow up on {record.name}',
      assignee: '{record.owner}',
      due_date: '{TODAY() + 7}',   // braces required — without them this writes the literal text
    },
  },
}

HTTP Request:

{
  id: 'notify_slack',
  type: 'http',
  label: 'Send Slack Notification',
  config: {
    url: 'https://hooks.slack.com/services/...',
    method: 'POST',
    body: { text: 'New order: {record.name}' },
  },
}

Script:

The built-in script executor dispatches on config.actionType (e.g. email) rather than evaluating an arbitrary JavaScript string:

{
  id: 'send_welcome',
  type: 'script',
  label: 'Send Welcome Email',
  config: {
    actionType: 'email',
    template: 'welcome',
    recipients: '{record.email}',
  },
}

Screen (object form):

A screen node normally renders a flat fields list. Set config.objectName to instead render an object's entire create/edit form — including any master-detail child grids (inlineEdit) — as one wizard step. The client renders the object form and, on save, persists the record itself (parent + inline children atomically); the flow then resumes with the new record's id bound to config.idVariable so a later step can reference it.

{
  id: 'new_opportunity',
  type: 'screen',
  label: 'Opportunity Details',
  config: {
    objectName: 'opportunity',         // render this object's full form
    mode: 'create',                    // 'create' (default) | 'edit'
    // recordId: '{account_id}',        // required for mode: 'edit'
    defaults: {                        // pre-filled values (interpolated)
      account: '{account_id}',
      stage: 'prospecting',
    },
    idVariable: 'opportunity_id',      // bind the saved record's id for later steps
  },
}

This is how a single flow walks the user through several full object forms in sequence (e.g. lead → account → opportunity), each step saving its own record.

Structured control flow (ADR-0031)

loop, parallel, and try_catch are structured control-flow constructs — the native, AI-authored model for iteration, concurrency, and error handling. Unlike free-form BPMN gateways (kept in the protocol for import/export interop only), these constructs are well-formed by construction: each owns its body as a self-contained, single-entry/single-exit region carried in config (representation B — a nested sub-graph). Ordinary step-to-step edges stay acyclic — iteration and concurrency are scoped containers, not back-edges — so the DAG invariant is preserved and termination stays analyzable. Regions are validated at registerFlow() (single-entry/single-exit, acyclic, bounded loop); a malformed construct is rejected before the flow can run.

A region runs in the enclosing variable scope (the iterator value and any body mutations are visible to the surrounding flow) — it is not a separate subflow invocation. The container node's ordinary out-edges are the "after-loop / after-block" continuation.

Loop container

Runs its body region once per item of a collection, binding the current item (and optionally its index) as flow variables, under a hard max-iteration guard.

{
  id: 'notify_each',
  type: 'loop',
  label: 'For each task',
  config: {
    collection: '{tasks}',        // template/variable resolving to an array
    iteratorVariable: 'task',     // current item, visible inside the body
    indexVariable: 'i',           // optional zero-based index
    maxIterations: 500,           // hard cap (clamped to the engine ceiling)
    body: {                       // single-entry/single-exit region
      nodes: [
        { id: 'send', type: 'script', label: 'Notify', config: { /* … */ } },
      ],
      edges: [],
    },
  },
}

A loop node with no body keeps the legacy flat-graph behavior — the container is additive.

Parallel block

Declares N branch regions that run concurrently and join implicitly when all complete — there is no author-visible split/join gateway. Branches should write distinct variables (last-writer-wins on collision); a failing branch fails the block.

{
  id: 'fan_out',
  type: 'parallel',
  label: 'Notify in parallel',
  config: {
    branches: [                   // ≥ 2 regions
      { name: 'Email', nodes: [{ id: 'email', type: 'script', label: 'Email', config: { /* … */ } }], edges: [] },
      { name: 'Slack', nodes: [{ id: 'slack', type: 'script', label: 'Slack', config: { /* … */ } }], edges: [] },
    ],
  },
}

Try / catch / retry

Wraps a protected try region; on failure runs the optional catch region (with the caught error bound to errorVariable), optionally retrying the try region first with exponential backoff. This surfaces the engine's existing fault + retry semantics as a structured construct (rather than BPMN boundary events).

{
  id: 'guarded',
  type: 'try_catch',
  label: 'Charge with fallback',
  config: {
    try: { nodes: [{ id: 'charge', type: 'http', label: 'Charge', config: { /* … */ } }], edges: [] },
    catch: { nodes: [{ id: 'flag', type: 'update_record', label: 'Flag failure', config: { /* … */ } }], edges: [] },
    errorVariable: '$error',
    retry: { maxRetries: 3, retryDelayMs: 1000, backoffMultiplier: 2 },
  },
}

BPMN parallel_gateway / join_gateway / boundary_event remain in the protocol as the interop representation and map onto these constructs on import/export — they are not the native authoring model.

Durable pause & resume (ADR-0019)

Some nodes don't finish in one pass — they suspend the run and wait for the outside world. The engine snapshots the run (variables, step log, position) to the sys_automation_run table and returns { status: 'paused', runId }; the pause survives a process restart and is continued later through a single entry point:

POST /api/v1/automation/{flow}/runs/{runId}/resume
{ "inputs": { … }, "branchLabel": "approve", "output": { … } }
Pausing node Suspends until… Resumed by
approval a human decision the approvals service (POST /api/v1/approvals/requests/:id/approve|reject) — resumes down the matching approve / reject edge. Always decide through the approvals API, never by calling resume directly: a direct resume strands the pending sys_approval_request, so the record stays locked and later approvals on the same record hit DUPLICATE_REQUEST.
screen a user submits the form the UI runner posting the collected inputs; a paused response carrying the next screen chains multi-step wizards under one stable runId
wait (timer) an ISO-8601 duration elapses automatically — a one-shot job resumes the run; after a cold boot the engine re-arms pending timers from the durable store (overdue timers resume immediately)
wait (signal) a named external event any caller invoking resume(runId)

Parallel approvals — one aggregating node, not two pauses

"Finance and legal must both sign off, concurrently" is one approval node with two approver groups and behavior: 'unanimous' — not two parallel pauses. On entry the node opens a single sys_approval_request whose pending_approvers holds both groups (notified concurrently); it stays suspended until every group has approved (the aggregation / AND), then resumes down approve. Any one rejection finalizes immediately down reject.

{
  id: 'dual_signoff',
  type: 'approval',
  label: 'Finance + Legal Sign-off',
  config: {
    approvers: [
      { type: 'position', value: 'finance' },
      { type: 'position', value: 'legal' },
    ],
    behavior: 'unanimous',   // 'first_response' = any one decides
    lockRecord: false,
  },
}

This is the aggregating-node pattern (Camunda multi-instance / Step Functions Map): the run keeps a single program counter and pauses once, so it needs no concurrent-token machinery. Durable pause inside a hand-drawn parallel branch or loop iteration — where two unrelated positions pause independently — is a separate, larger capability (ADR-0039 Track B); the aggregating node covers the common parallel/batch-approval demand without it. Worked example: showcase_invoice_signoff in the showcase app.

Batch approval — a map node, one item at a time

"Sign off every task in the release, in turn" is a map node: it runs a per-item subflow for each element of a collection, sequentially. Unlike loop (whose body runs synchronously and cannot pause), each item is a full child run, so the per-item subflow may durably pause on its own approval — the map waits for that item's decision, then moves to the next.

{
  id: 'signoffs',
  type: 'map',
  label: 'Sign off each task',
  config: {
    collection: '{items}',          // array (e.g. the release's tasks)
    iteratorVariable: 'task',
    flowName: 'one_task_signoff',    // the per-item subflow (it may pause)
    itemObject: 'showcase_task',     // when an item is a record, it becomes the child's `$record`
    outputVariable: 'signoffResults',// each item's subflow output, collected in order
  },
}

The run holds a single program counter the whole time: only one item's approval is open at any moment; when it is decided the engine re-enters the map node to start the next item. v1 is sequential and fail-fast (the first item whose subflow fails fails the map). Concurrent fan-out (all items at once) is the larger ADR-0039 Track B work. Worked example: showcase_release_signoffshowcase_one_task_signoff.

Nested pause — pausing inside a subflow

A pausing node inside a subflow suspends the whole chain as linked runs: the child run pauses at its node, and the parent pauses at the subflow node (correlation: 'subflow:<childRunId>'). Both continuations are durable, and the chain resolves from either end:

  • deciding the child's approval (or its timer firing) completes the child and bubbles its output back into the parent, which continues with the same ${nodeId}.output / outputVariable mapping as a synchronous subflow;
  • resuming the parent run id (what a UI holds from the original launch) delegates down to the suspended child — multi-screen child wizards keep the parent run id stable.

A child that fails terminally after the pause fails every waiting ancestor, so no run is stranded as resumable-forever. See the worked example pair in the showcase app: showcase_project_closure invokes the reusable showcase_closure_signoff approval subflow and notifies the owner with the bubbled decision.

Observing runs

GET /api/v1/automation/{flow}/runs                 # run history (status, steps, error)
GET /api/v1/automation/{flow}/runs/{runId}         # one run
GET /api/v1/automation/{flow}/runs/{runId}/screen  # re-fetch a paused screen

Each run's steps[] records every executed node — including loop iterations, parallel branch bodies, and try/catch region steps — and the Studio flow designer surfaces the same data in its Runs side panel. Run history is an in-memory ring buffer (the durable rows in sys_automation_run hold only live pauses), so history starts fresh on each boot.

Edges

Edges connect nodes and define the execution path:

{
  id: 'edge_1',
  source: 'check_status',
  target: 'send_email',
  type: 'default',
  condition: "status == 'approved'",
  label: 'Approved',
}
Property Type Required Description
id string Unique edge identifier
source string Source node ID
target string Target node ID
type enum optional 'default' (success), 'fault' (error), 'conditional' (expression-guarded), or 'back' (declared back-edge, ADR-0044); defaults to 'default'
condition string optional Boolean expression for branching
label string optional Label displayed on the connector

Variables

Flows use variables to pass data between nodes and to/from callers:

variables: [
  { name: 'input_id', type: 'text', isInput: true, isOutput: false },
  { name: 'result', type: 'object', isInput: false, isOutput: true },
  { name: 'counter', type: 'number', isInput: false, isOutput: false },
]
Property Type Description
name string Variable name
type string 'text', 'number', 'boolean', 'object', 'list'
isInput boolean Available as input parameter
isOutput boolean Available as output parameter

Error Handling

Configure how errors are handled during flow execution:

errorHandling: {
  strategy: 'retry',           // 'fail' | 'retry' | 'continue'
  maxRetries: 3,
  retryDelayMs: 5000,
  fallbackNodeId: 'error_handler',
}
Property Type Description
strategy enum 'fail' (stop), 'retry' (retry), 'continue' (skip)
maxRetries number Maximum retry attempts (0-10)
retryDelayMs number Delay between retries (ms)
fallbackNodeId string Node to execute on failure

Discovery & Registration

You almost never call engine.registerFlow() directly. The AutomationServicePlugin (@objectstack/service-automation) auto-discovers every inline flow definition during its start() phase by reading the ObjectQL schema registry:

// inside AutomationServicePlugin.start()
const ql = ctx.getService('objectql');
const flows = ql.registry.listItems('flow');
for (const flow of flows) {
  engine.registerFlow(flow.name, flow);
}

Any flow attached via the defineStack / manifest.register() pipeline is picked up automatically:

import { defineStack } from '@objectstack/spec';
import { approvalFlow } from './flows/approval';

export default defineStack({
  manifest: { id: 'com.example.crm', version: '1.0.0', type: 'app', name: 'CRM' },
  objects: [...],
  flows: [approvalFlow],   // ← registered with the engine on boot
});

The plugin is a soft dependency on metadata — it tolerates running without MetadataPlugin and it logs (not throws) on per-flow registration failures so one broken flow does not abort startup.

Expressions in flows

A flow mixes three expression dialects, and using the wrong one is the single most common way a flow silently misbehaves. Which dialect applies is decided by where the expression sits — not by what it looks like:

Where Dialect Write it like Bindings
Start-node condition CEL (bare, no braces) record.amount > 500 record.*, previous.*, bare field names, vars.*
Edge condition CEL (bare, no braces) record.status == 'open' same as above
Decision-node conditions[].expression Template compare (braces required) {order_amount} > 10000 flow variables by name, in {…}
Field values in create_record / update_record Interpolation (braces required) 'Follow up on {record.name}', '{TODAY() + 7}' {var}, {var.path}, {$User.Id}, {$User.Email}, {NOW()}, {TODAY()}, {TODAY() + 90} (whole days)
**The two failure modes to memorize:**
  1. Braces missing in a decision expression'order_amount > 10000' isn't evaluated as a variable at all. It compares the string "order_amount" against "10000", which is always true, so the flow always takes the first branch and never tells you. Write '{order_amount} > 10000'.
  2. Braces missing in a field valuedue_date: 'TODAY() + 7' writes the literal text TODAY() + 7 into the field. Write '{TODAY() + 7}'.

The mirror mistake is putting braces into a CEL condition ('{record.amount} > 500') — CEL conditions fail loudly rather than silently, with an error that tells you to drop the braces.

CEL conditions that fail to evaluate raise an error and stop the run — they never quietly pass. See Formulas for the CEL language itself.

**A boolean gotcha:** SQLite/libSQL store booleans as `0`/`1`, and CEL's `1 != true`. Compare against the stored shape (`record.done == 1`) or normalize the value before the condition.

Run a flow via API

Flows of any type can be launched over HTTP — this is what an external system, a scheduled job outside the platform, or the Console's test runner uses:

curl -b cookies.txt -X POST \
  https://your-app.example.com/api/v1/automation/order_approval/trigger \
  -H "Content-Type: application/json" \
  -d '{ "recordId": "ord_123", "objectName": "order", "params": { "priority": "high" } }'
# → { "success": true, "data": { ... }, "meta": { ... } }
Endpoint Purpose
POST /api/v1/automation/:name/trigger Start a flow (canonical)
GET /api/v1/automation/:name/runs List runs (?limit, ?cursor)
GET /api/v1/automation/:name/runs/:runId One run's detail (404 Execution not found)
POST /api/v1/automation/:name/runs/:runId/resume Resume a paused run — body { inputs, output, branchLabel }
GET /api/v1/automation/:name/runs/:runId/screen The pending screen of a screen-flow run

Passing inputs. Declare variables with isInput: true, then send them in params under the same names — only declared inputs are bound. recordId and objectName are lifted into params for you (plus an <objectName>Id alias), and event defaults to 'manual'.

Identity. The caller's identity (user, positions, permissions, tenant) is forwarded into the run, so a runAs: 'user' flow executes under that caller's row-level security. See API Authentication for credentials.

**`success: true` doesn't always mean "it ran".** If the start condition isn't met, the response is a *successful* skip: `{ "success": true, "data": { "skipped": true, "reason": "condition_not_met" } }`. A self-triggering flow caught by the loop guard reports `reason: "reentrancy_loop_guard"` the same way. Check `skipped` before assuming the work happened.

Console Flow Viewer & Test Runner

Every flow can surface in the Console metadata browser under /_console/. ObjectUI's flow viewer replaces the default JSON inspector with rich tabs when the flow viewer plugin is installed:

Tab Component Purpose
Overview FlowViewer Renders trigger type, variables, nodes, edges, and errorHandling as inspector cards
Run FlowTestRunner Auto-generates a form for every isInput: true variable (with type-aware coercion for number / boolean / object / list), executes the flow against the per-project kernel, and shows the returned outputs + run id
Runs FlowRunsPanel Lists historical executions for the selected flow with status, duration, and a deep-link to the run record

The runner posts to the same POST /api/v1/automation/:name/trigger endpoint, scoped to the active project. All three components participate in the Studio authentication / project-scope context, so they work identically in single-project mode and in cloud mode.

Flows in practice

Flows automate business processes as explicit node graphs. Use them for field updates, notifications, record creation, HTTP calls, decisions, waits, screens, subflows, and approval pauses. The old standalone Workflow Rule authoring model is retired; model the same logic as a Flow.

Record-change flow

import type { Flow } from '@objectstack/spec/automation';

export const hotLeadFollowUp: Flow = {
  name: 'hot_lead_follow_up',
  label: 'Hot Lead Follow Up',
  type: 'record_change',
  status: 'active',
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Start',
      config: {
        triggerType: 'record-after-create',
        objectName: 'lead',
        condition: "record.rating == 'hot'",
      },
    },
    {
      id: 'create_task',
      type: 'create_record',
      label: 'Create Follow-up Task',
      config: {
        object: 'task',
        fields: {
          subject: 'Follow up on hot lead',
          related_to: '{record.id}',
          priority: 'high',
        },
      },
    },
    {
      id: 'notify_owner',
      type: 'notify',
      label: 'Notify Owner',
      config: {
        recipients: '{record.owner}',
        title: 'New hot lead',
        message: 'Hot lead created: {record.name}',
        channels: ['inbox'],
      },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'create_task' },
    { id: 'e2', source: 'create_task', target: 'notify_owner' },
    { id: 'e3', source: 'notify_owner', target: 'end' },
  ],
};

Scheduled flow

export const contractExpirationCheck: Flow = {
  name: 'contract_expiration_check',
  label: 'Contract Expiration Check',
  type: 'schedule',
  status: 'active',
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Start',
      config: {
        triggerType: 'schedule',
        schedule: { type: 'cron', expression: '0 0 * * *', timezone: 'UTC' },
      },
    },
    { id: 'find_expiring', type: 'get_record', label: 'Find Expiring Contracts' },
    { id: 'notify_owners', type: 'notify', label: 'Notify Owners' },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'find_expiring' },
    { id: 'e2', source: 'find_expiring', target: 'notify_owners' },
    { id: 'e3', source: 'notify_owners', target: 'end' },
  ],
};

Update-triggered flow

Trigger on a record update and compare against the previous value:

// Flow: Update probability
flows: [
  {
    name: 'update_probability',
    label: 'Update Probability',
    type: 'record_change',
    status: 'active',
    nodes: [
      {
        id: 'start',
        type: 'start',
        label: 'Start',
        config: {
          triggerType: 'record-after-update',
          objectName: 'opportunity',
          condition: 'record.stage != previous.stage',
        },
      },
      { id: 'update_probability', type: 'update_record', label: 'Update Probability' },
      { id: 'end', type: 'end', label: 'End' },
    ],
    edges: [
      { id: 'e1', source: 'start', target: 'update_probability' },
      { id: 'e2', source: 'update_probability', target: 'end' },
    ],
  },
],

Best practices

DO:

  • Keep flows simple and focused
  • Document the business logic
  • Test recursion and retry behavior
  • Use scheduled flows for batch updates

DON'T:

  • Create uncontrolled loops
  • Update too many fields in one node
  • Use triggers when a declarative flow is enough
  • Mix unrelated concerns in one flow

Related