objectstack-ai/objectstack

objectstack-automation

Design ObjectStack automation — Flows (visual logic), Triggers, Approvals, state machines, and the jobs (defineJob) / webhooks (defineWebhook) stack collections.

ソースを見る
リポジトリの原文

見出し、例、コード、表、リンク、参照画像を含む原文を表示しています。

Automation Design — ObjectStack Automation Protocol

When to Use This Skill

  • You are building a visual flow (auto-launched, screen, or scheduled).
  • You need a state machine or approval process for a business object.
  • You are setting up event-driven triggers (record create/update/delete).
  • You need scheduled automation (daily reports, data cleanup).
Predicates and conditions are CEL — every condition / guard / entryCondition / filter value here is an Expression envelope evaluated by @objectstack/formula. A slot takes a plain CEL string; the P\...\` / cel\...\` tags wrap the same string with author-time validation. Both parse — pick one per file (the example apps use plain strings). See objectstack-formula for the CEL contract, stdlib and legacy → CEL table.

Flows — Visual Logic Orchestration

A Flow is a directed graph of nodes that execute sequentially or in parallel. Flows are the primary automation building block in ObjectStack.

Flow Types

TypeWhen to Use
autolaunchedRuns without user interaction — triggered by events, APIs, or other flows
screenInteractive — presents UI screens to the user (wizards, forms)
scheduleRuns on a cron/interval cadence declared on the start node's `config.schedule` (daily cleanup, weekly reports) — or a per-record date sweep via config.timeRelative, see Time-relative triggers
record_changeFires automatically on record create/update/delete (bind via the start node's triggerType). autolaunched + the same record-* binding behaves identically — the engine reads the start node either way; record_change also opts into the trigger-readiness lint
apiInvoked explicitly via the API / engine.execute(), or bound as an inbound webhook: POST /api/v1/automation/hooks/:flowName/:hookId (see Inbound webhook triggers below)

Flow Node Types

Flows are built from 20 built-in node types (the FlowNodeAction seed set — plugins register more via registerNodeExecutor, e.g. approval below):

Control Flow

NodePurpose
startEntry point — every flow has exactly one
endExit point — can have multiple (early exit, error exit)
decisionConditional branching — routed by edge `condition` predicates, not node config (see the approval example below)
loopIterate a nested `config.body` region once per item of config.collection; iteratorVariable (default item) and optional indexVariable bind inside it, maxIterations caps it
parallelFan out into config.branches[] (≥ 2 regions) run concurrently, joined implicitly at block end — no split/join pair to mis-wire
try_catchRun config.try; on failure run config.catch with the error in errorVariable (default $error); config.retry re-runs try with backoff first. No `finally` — the container's ordinary out-edges are the continuation
mapSequential multi-instance — invoke a subflow once per item of a collection; each iteration may pause (batch approvals)
waitPause execution until a timer elapses or a named signal arrives
subflowInvoke another flow (reusable composition)
parallel_gateway / join_gateway / boundary_eventNot author-facing — BPMN-interop forms the mapper lowers a parallel / try_catch container INTO (automation/control-flow.zod.ts). Author the container

Data Operations

NodePurpose
assignmentSet variable values
create_recordInsert a new record
update_recordModify existing records
delete_recordRemove records
get_recordFetch records with filters — there is no `query_record` node (that name has no executor and throws)

External Integration

NodePurpose
httpCall an external HTTP API — canonical since protocol 11.0; http_request survives only as a deprecation-window alias
notifySend a notification through the messaging service (inbox channel by default)
connector_actionInvoke a pre-built integration connector
scriptCall a registered function named by config.function (see Valid-but-silently-wrong #3)
screenDisplay a UI form to the user (screen flows only)

Human Decision

NodePurpose
approvalRoute a record for human sign-off — suspends the run until a decision, then continues down the approve / reject branch (contributed by plugin-approvals)

notify — the most-used node type

NotifyConfigSchema (automation/io-node-config.zod.ts) is strictObject — an undeclared key is a named parse error. RAW keys never interpolate: a {token} in one is forwarded verbatim, never resolved.

ts
{ id: 'tell_owner', type: 'notify', label: 'Notify Owner', config: {
    recipients: '{record.assignee}',   // REQUIRED — id, CSV, or string[]
    title: 'Done: {record.title}',     // inline path; XOR `template` (RAW, localizable)
    message: 'Closed by {$User.Id}',   // body; only with inline `title`
    topic: 'task',                     // RAW; default 'notify'
    severity: 'warning',               // RAW; CLOSED enum info|warning|critical
    channels: ['inbox'],               // RAW; default inbox
    sourceObject: 'task',              // click-through: a PAIR, else dropped
    sourceId: '{record.id}',           //   at execute time
    actionUrl: 'https://…/tasks/123',  // overrides the synthesized link
} }

Flow Variables

Every flow defines input/output variables. variables is an array of { name, type, isInput, isOutput } entries — not a name-keyed map, and there is no label property on a variable:

typescript
variables: [
  {
    name: 'case_id',
    type: 'text',
    isInput: true,    // passed in when flow is invoked
    isOutput: false,
  },
  {
    name: 'approval_result',
    type: 'boolean',
    isInput: false,
    isOutput: true,   // returned when flow completes
  },
],

Flow Example — Auto-Escalate Overdue Cases

Nodes connect via `edges`, not a `next` property. The engine traverses flow.edges ({ source, target }); a bare next: on a node is refused. update_record selects rows with `filter` — an ObjectQL where map of field → value / field → { $operator: value }, NOT the UI view-filter [{ field, operator, value }] triples — and writes with `fields` (a single call updates every matching row — no per-row loop needed). label is required on the flow and on every node, and every path through the graph must reach an end node.

<!-- os:check -->

typescript
import { defineFlow } from '@objectstack/spec';

export const EscalateOverdueCasesFlow = defineFlow({
  name: 'escalate_overdue_cases',
  label: 'Escalate Overdue Cases',
  type: 'schedule',
  status: 'active',
  runAs: 'system',   // a scheduled run has no trigger user — elevate explicitly
  nodes: [
    {
      id: 'start',
      type: 'start',
      label: 'Daily at 09:00',
      // The cadence lives HERE, on the start node's config — FlowSchema has NO
      // top-level `schedule` key (one there is a named parse error, not a silent
      // strip). A bare cron string also works: schedule: '0 9 * * *'. Do NOT use
      // the cron`…` tagged template — its envelope is not a recognized shape.
      config: { schedule: { type: 'cron', expression: '0 9 * * *' } },
    },
    {
      id: 'escalate_overdue',
      type: 'update_record',
      label: 'Escalate Overdue Cases',
      config: {
        objectName: 'support_case',
        // which rows to update — `filter` is a `where` map, not filter triples
        filter: {
          status: { $in: ['new', 'open'] },
          due_date: { $lt: '{TODAY()}' },   // template token → today's date at run time
        },
        // what to write — `fields`, not `values`
        fields: { status: 'escalated' },
      },
    },
    {
      id: 'notify_manager',
      type: 'http',
      label: 'Notify Manager',
      config: {
        url: 'https://hooks.slack.com/services/...',
        method: 'POST',
        body: { text: 'Escalated overdue support cases.' },
        timeoutMs: 10000,   // unset = NO timeout at all — always set one
      },
    },
    { id: 'end', type: 'end', label: 'End' },
  ],
  edges: [
    { id: 'e1', source: 'start',            target: 'escalate_overdue' },
    { id: 'e2', source: 'escalate_overdue', target: 'notify_manager' },
    { id: 'e3', source: 'notify_manager',   target: 'end' },
  ],
});

Failure routing & runAs

Handling a failed node: a `fault` edge. { source, target, type: 'fault' } routes a failed node to a handler instead of ending the run. `type: 'fault'` is what routes — a `label: 'error'` alone does nothing: the edge stays ordinary, and every unconditional out-edge traverses on SUCCESS, so the handler would run when the node succeeds and never when it fails (objectstack validate reports flow-error-label-not-fault). A handled failure does NOT consume a flow-level errorHandling.retry, which replays the flow from the start — prefer a fault edge when the failure is local. The handler reads {<nodeId>.error} (or run-wide {$error}). The run then reports success, and the failed step stays in the trace. It is not a way past a guardrail.

| ROUTES (runtime failure) | Does NOT route (fatal either way) | |:--|:--| | 404, rate-limit, rejected write, failed subflow | missing required config key (objectName, url, flowName, connectorId/actionId); filter token that resolved to nothing; graph past the nesting ceiling; unscoped run |

Routing a guard refusal is worse than the failure: a dropped filter condition widens the query, so a routed delete_record empties the object while the run reports success. objectstack validate names the offending template.

Writing a `readonly` field? Set `runAs: 'system'`. readonly: true governs the end-user surface: under the default runAs: 'user', the engine strips a readonly field from any non-system write — create_record and update_record alike — the step reports success but the value never lands, and the drop is named in the step's warnings. A flow that maintains a readonly field (approval stamps, conversion flags, SLA markers, rollups) must run runAs: 'system', the trusted-writer channel. os validate / os build fail a runAs:'user' update_record that writes a readonly field, so the mismatch surfaces at build time, not as wrong data days later. (readonlyWhen fields are the same story, per record state — flagged as a warning.) Do not work around this by removing readonly; that loses the field's edit protection.
Elevate the write, not the flow. A screen flow stays runAs: 'user'. When one step in it must write a readonly field, move that step into a dedicated runAs: 'system' flow and call it from a subflow node — raising the whole flow silently elevates every other write in it. A `runAs: 'system'` sweep must pin its organization. System context has no trigger user, so nothing narrows the query: a scan or rollup with no organization predicate reads and writes across every tenant. The tenant column is platform-injected — filter on it, never re-declare it per object.
A hook elevates itself with `runAs`, never with `sudo`. An object hook (objectstack-data) declares its own runAs: 'system' | 'user' | 'inherit' — default 'inherit', the context of the write that fired it — scoping that hook's ctx.api data operations only, on the in-process handler and the sandboxed body alike. A 'user' hook whose trigger resolved no user has nothing to scope to: its ctx.api data operations are refused (HOOK_UNSCOPED_DATA_ACCESS, 403) rather than run unscoped — declare runAs: 'system' when the elevation is intended. sudo is not a hook key.

Filter tokens (config.filter)

The one slot where two {…} dialects meet, and the one whose failure widens a query instead of narrowing it.

  • Precedence — flow variables win, placeholders pass through. The flow

template engine runs first. A whole-string token it resolves is a flow value; one it does not resolve that IS a recognised filter placeholder ({current_user_id}, {current_year_start}) passes through verbatim for the query engine to expand. So a flow variable named after a placeholder shadows it. Only filter gets this hand-off — in title, message, fields and url a bare {current_year_start} is a nonsense reference.

  • Static checkability splits by position. A {record.…} token **inside a

filter naming an unknown field, or hopping a relation the start node does not list in `config.expand`, is an ERROR* at `objectstack validate`: it resolves to nothing, the condition is DROPPED, and the node refuses to execute. The same reference outside a filter (message body, `http` url, write payload) only renders an empty string — a warning. A `{var}` naming a flow variable or node output is not statically checkable at all*.


Valid-but-silently-wrong (passes build, fails at runtime)

These are legal metadata that authors — AI especially — get wrong. Most are now caught by objectstack build (a hard error, or an advisory warning), but write them right the first time:

  1. Flow node VALUE interpolation uses SINGLE braces. Value fields on a node's

config (fields, inputs, notify message/title, …) interpolate {token}:

  • {var} / {record.title} — variable / record field
  • {record.tags.0}array index (e.g. a multiple: true lookup, stored as an array)
  • {$User.Id} / {NOW()} / {TODAY() + 30} — current user / date macros
  • {round(x)} {floor(x)} {ceil(x)} {abs(x)} {min(a,b)} {max(a,b)}

mirror the CEL stdlib 1:1. round is integer-only (no round(x, 2)); for N decimals write {round(x * 100) / 100} (scale 2)

  • anything without {…} is a literal

body: '{{ai_reply}}' — double-brace is the formula / template-field dialect, not flow values ❌ ticket: '$source.id' — a bare $ref is a literal string, not interpolated ✅ body: '{ai_reply}', ticket: '{source.id}''{ROUND(x, 2)}' / '{Math.round(x)}' / '{(x).toFixed(2)}' — any other name in call position fails the node with a named error naming the supported set. The build does not catch these (conditions are checked, call-position names are not) and a fault edge cannot route it.

  1. `create_record`'s `outputVariable` holds the created RECORD, not its id.

Reference a field explicitly. ❌ update_record … fields: { ref: '{newRec}' } → yields the whole record object ✅ fields: { ref: '{newRec.id}' }

  1. `script` nodes call a registered function — that is all they do. Set

config.function to a function registered via defineStack({ functions: { my_fn: (ctx) => … } }). It is required: an empty script node refuses at execute, and one pointing at an unregistered function fails loudly.

There is no other dispatch form: use `notify` for delivery, a `connector_action` or http webhook for Slack, a function for logic.

A flow `function` is a PURE compute step — it does NOT read/write the database. It receives ctx.input and returns a value; config.outputVariable exposes that value as a flow variable, and a later declarative node persists it. Keep data effects on the flow graph (visible, governed, build-checkable):

ts
   // ❌ DON'T: expect the function to update the record itself (it has no data API)
   // ✅ DO: function returns values → outputVariable → update_record persists
   { id: 'ai', type: 'script', config: {
       function: 'helpdesk.aiTriageStub',     // returns { ai_category, ai_sentiment, … }
       inputs: { ticketId: '{record.id}' },   // inputs are interpolated
       outputVariable: 'ai',
   } },
   { id: 'apply', type: 'update_record', config: {
       objectName: 'helpdesk_ticket',
       filter: { id: '{record.id}' },
       fields: { ai_category: '{ai.ai_category}', ai_sentiment: '{ai.ai_sentiment}' },
   } },

defineStack({ functions: { 'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other', … }) } }). If you genuinely need data-lifecycle side effects (read/write other records), that's an L2 hook (objectstack-data) — hooks get ctx.api; flow functions don't.

A function that writes where the platform cannot see declares it, so the run reports "cannot say" rather than acted: 0:

ts
   defineStack({ functions: {
     'helpdesk.aiTriageStub': (ctx) => ({ ai_category: 'other' }),  // pure — the default
     'billing.sync': { handler: syncBilling, effect: 'writes' },    // declared writer
   } });
  1. Conditions are bare CEL — the stdlib is what you may call bare. now(),

today(), daysFromNow(n), daysAgo(n), daysBetween(a, b), isBlank(v), coalesce(a, b), abs/round/min/max, upper/lower/contains/matches, plus CEL built-ins (has, size, int, string, …) — see objectstack-formula for that table: it is CEL_STDLIB_FUNCTIONS, the bare-callable public subset, so receiver methods (called on a value, never bare) are not in it. An UNKNOWN function (PRIOR(), a typo'd name) and a {…}-wrapped field ref both fail the build: a brace is a template, not CEL — write record.x, not {record.x}.

  1. `notify` reports SUCCESS when the `messaging` capability is absent. The

executor logs no messaging service registered and returns success with output: { delivered: 0, failed: 0, skipped: true } and metrics.acted: 0 — a green run that delivered nothing. Declare messaging in requires.


State Machines & Approvals

A record's state machine locks the legal transitions of its status field so that automation — increasingly AI-generated — cannot drive a record into an illegal state.

State Machine — a state_machine validation rule (ADR-0020)

Since ADR-0020 there is no `workflow` metadata type and no object.stateMachines map. A record state machine is one `state_machine` validation rule in the object's validations array: a flat field + { from: [allowedTo] } transition table. It is enforced on the write path — an update whose field moves to a state not listed for the current state is rejected with the rule's message. A from state mapped to [] is a declared dead-end.

typescript
{
  type: 'state_machine',
  name: 'case_lifecycle',
  label: 'Case Lifecycle',
  field: 'status',                 // the field that holds the state
  message: 'Invalid status transition.',
  initialStates: ['new'],          // states a record may be CREATED in
  transitions: {
    new:       ['open'],
    open:      ['escalated', 'resolved'],
    escalated: ['open', 'resolved'],
    resolved:  ['open', 'closed'],
    closed:    [],                 // final — no outgoing transitions
  },
}

Notes:

  • One rule per field. Parallel lifecycles (e.g. status + payment_status)

are N separate state_machine rules, one per field.

  • `initialStates` (optional) gates INSERT: a record created with its

state field outside this list is rejected. transitions only governs updates, so without it a record can be born mid-flow (e.g. created already resolved). Omit to keep the legacy no-check-on-insert behavior.

  • Conditional transitions / side effects are NOT part of the machine. A

guard is expressed as a sibling script / conditional validation rule; "do something when the state changes" is a record-triggered Flow (ADR-0019) — a record_change flow whose start-node condition gates on the transition, e.g. previous.status != 'escalated' && record.status == 'escalated'.

  • Introspection: GET /api/v1/meta/object/:name/state/:field?from=:state

returns the legal next states so UIs/agents can read the transition table instead of hard-coding it (next: null = no FSM governs the field, or ?from= was omitted — always pass from).

  • An unlisted `from` state is NOT guarded. An update whose current state is

not a key of transitions is treated leniently (no lock) — list every state you want guarded rather than relying on an implicit "any → any".

  • Predicate conditions in sibling rules evaluate against the merged record in

the `record.<field>` CEL scope (bare field names do not resolve).

Approvals (Flow Nodes)

Since ADR-0019 there is no standalone approval-process type. An approval is authored as an Approval node (type: 'approval') on an ordinary flow — the run suspends when it reaches the node and resumes down the node's approve / reject out-edge once a decision is recorded. Multi-step review is just successive Approval nodes wired together on the canvas, so the whole review is one diagram a reviewer (or AI) can read end-to-end.

There is no approvals: [...] stack collection — approval flows live in your normal flows: [...]. The approval state (sys_approval_request / sys_approval_action, the record lock, the status mirror, approver resolution) is owned by plugin-approvals.
typescript
// A record-triggered flow: high-value opportunities need manager sign-off,
// and director sign-off too when the amount clears 500k.
{
  name: 'opportunity_discount_approval',
  label: 'Opportunity Discount Approval',
  type: 'record_change',
  nodes: [
    // Record-change flows bind via the START NODE's config — there is no
    // separate top-level `trigger`. `triggerType` is one of
    // `record-(before|after)-(create|update|delete)`; `condition` (bare CEL)
    // gates whether the flow launches.
    {
      id: 'start',
      type: 'start',
      label: 'On Opportunity Update',
      config: {
        objectName: 'opportunity',
        triggerType: 'record-after-update',
        condition: cel`record.amount > 100000`,
      },
    },
    {
      id: 'manager_review',
      type: 'approval',
      label: 'Sales Manager Review',
      config: {
        approvers: [{ type: 'position', value: 'sales_manager' }],
        behavior: 'first_response',            // or 'unanimous' / 'quorum' / 'per_group'
        lockRecord: true,                      // lock the record while pending
        approvalStatusField: 'approval_status', // mirror pending|approved|rejected|recalled onto the row
      },
    },
    // Decision routing lives on the OUT-EDGES, not in node config: the engine
    // evaluates each out-edge's `condition` and follows every match — and an
    // out-edge with NO condition ALWAYS runs (all such edges execute in
    // PARALLEL). Guard every branch with a condition — see e4/e5 below.
    { id: 'needs_director', type: 'decision', label: 'Needs Director?' },
    {
      id: 'director_signoff',
      type: 'approval',
      label: 'Sales Director Sign-off',
      config: {
        approvers: [{ type: 'position', value: 'sales_director' }],
        behavior: 'unanimous',
        approvalStatusField: 'approval_status',
      },
    },
    { id: 'mark_won', type: 'update_record', label: 'Mark Won',
      config: { objectName: 'opportunity', filter: { id: '{record.id}' }, fields: { stage: 'closed_won' } } },
    { id: 'approved', type: 'end', label: 'Approved' },
    { id: 'rejected', type: 'end', label: 'Rejected' },
  ],
  edges: [
    { id: 'e1', source: 'start',          target: 'manager_review',
      // entry criteria re-homes onto the edge entering the approval node:
      condition: cel`record.amount > 100000` },
    { id: 'e2', source: 'manager_review',  target: 'needs_director',   label: 'approve' },
    { id: 'e3', source: 'manager_review',  target: 'rejected',         label: 'reject'  },
    // Decision branches: mutually-exclusive edge `condition` predicates.
    // Without them BOTH branches would execute (unguarded edges run in parallel).
    { id: 'e4', source: 'needs_director',  target: 'director_signoff', label: 'true',
      condition: cel`record.amount > 500000` },
    { id: 'e5', source: 'needs_director',  target: 'mark_won',         label: 'false',
      condition: cel`record.amount <= 500000` },
    { id: 'e6', source: 'director_signoff', target: 'mark_won',        label: 'approve' },
    { id: 'e7', source: 'director_signoff', target: 'rejected',        label: 'reject'  },
    { id: 'e8', source: 'mark_won',         target: 'approved' },
  ],
}

Send-back for revision (ADR-0044)

Approval centers also model send back for revision (退回修改) — distinct from reject (terminate) and from a comment thread (which keeps the request pending). Send-back is a flow movement: the request finalizes as returned, the run walks a `revise` out-edge to an `approval_revise` node (the revise window) where the record unlocks and the submitter reworks it, and an explicit resubmit re-enters the approval node over a declared back-edge, opening round N+1 with a fresh approver slate.

approval ──approve──▶ …
         ──reject───▶ …
         ──revise───▶ approval_revise (record unlocked, submitter edits)
                        └──resubmit──[type:'back']──▶ approval   (round N+1)

Three pieces author it:

  1. `revise` out-edge — a third branch label alongside approve / reject,

targeting an `approval_revise` node. It must be that node type: the window is a service-owned pause (resumeAuthority: 'service'), ended only by POST /api/v1/approvals/requests/:id/resubmit; a wait is resumeAuthority: 'any', so a raw run-resume would walk the back-edge unchecked. The node takes no config — there is no signal to wait on.

  1. `type: 'back'` resubmit edge — the edge from the revise window back into

the approval node MUST be typed 'back'. This is the only thing that legalizes the cycle: registerFlow validates the graph minus `back` edges as a DAG, so an unmarked cycle is rejected — you opt in, edge by edge. At run time a back-edge traverses normally (it just re-enters the node).

  1. `maxRevisions` on the approval config (default 3) — the budget of

send-backs per run; exceeding it auto-rejects (resumes down the reject edge). maxRevisions: 0 disables send-back, so never pair 0 with a revise edge.

typescript
{
  id: 'manager_review', type: 'approval', label: 'Manager Review',
  config: { approvers: [{ type: 'position', value: 'manager' }], lockRecord: true, maxRevisions: 2 },
},
// No config and no `waitEventConfig`: the window ends on the submitter's
// explicit resubmit, not on a signal or a timer.
{ id: 'wait_revision', type: 'approval_revise', label: 'Awaiting Revision' },
// …among the approval's edges…
{ id: 'rev',  source: 'manager_review', target: 'wait_revision',  label: 'revise' },
{ id: 'back', source: 'wait_revision',  target: 'manager_review', label: 'resubmit', type: 'back' },
Three mistakes the compile-time flow lint flags: a revise edge into anything but an approval_revise node (an errorsendBack refuses that metadata, so the branch cannot run; flow-approval-revise-target-not-service-owned), a revise edge whose window never loops back (a dead end registerFlow accepts but that leaves the submitter nowhere to resubmit), and a resubmit edge left without type: 'back' (an unmarked cycle registerFlow rejects). Resubmit is an explicit verb (POST /api/v1/approvals/requests/:id/resubmit), never a record-save. See the showcase_budget_approval flow in the showcase app in the framework repo for the canonical shape.

Recording a decision

A decision is recorded through ApprovalService.decide() (or the REST routes POST /api/v1/approvals/requests/:id/approve | /reject). That finalizes the sys_approval_request and resumes the suspended run down the matching branch — you never resume the flow by hand, and you cannot: the approval node declares resumeAuthority: 'service', so POST /api/v1/automation/:name/runs/:runId/resume answers 403 for a run parked on one (including via a subflow pause) and changes nothing.

A decision may also carry structured outputs ({ outputs: { … } } in the decide body) when the node declares the keys in decisionOutputs — the author declares keys, approvers only fill values. Accepted outputs resume the run as <nodeId>.<key> flow variables, so a LATER node reads them as vars.<nodeId>.<key> — this is how "the previous approver picks the next step's approvers" works without writing to a record field (see Dynamic approvers below). A decision carrying an undeclared key is rejected; decision / requestId are reserved. A declaration marked required: true must carry a non-blank value to approve (never to reject) — enforced before any write, with no elevation bypass, so the run cannot resume past the node with the key a later expression approver reads still missing.

Approver Types

typeResolves to
userA specific user id (value = user id)
positionHolders of a position — value = the position machine name, resolved via sys_user_position (ADR-0090 D3)
org_membership_levelThe org-membership tiervalue is one of owner/admin/delegated_admin/member. NOT a position: { type: 'org_membership_level', value: 'sales_manager' } matches nobody; use position. Spelled role before ADR-0090 D3 — that spelling is deprecated, still resolves, and is removed in the next major
teamMembers of a flat sys_team
departmentA department + all descendant departments
managerThe submitter's manager (sys_user.manager_id)
fieldUser id read from a record field (value = field name). Resolved against the record's live state at node entry, so a field written mid-flow routes correctly; a multi-select user field fans out into one approver per user
queue⛔ Declared but never resolved — the slot routes to nobody. Do not author
expressionA CEL expression resolved at node entry (value = the expression) — see Dynamic approvers below. Only current.* / trigger.* / vars.* roots are available; the optional `resolveAs: 'user'(default) \'department' \'position' \'team'` re-expands each resolved id through the graph

Dynamic approvers (type: 'expression')

An expression approver computes WHO approves at the moment the node is entered. Its CEL source sees exactly three roots — nothing else:

RootMeaningAnalog
current.*The record's live state at node entry — fields written by earlier steps/approvers are visibleServiceNow current
trigger.*The submit-time snapshot (what flow conditions call record)ServiceNow Flow Designer trigger.record, Power Automate triggerBody()
vars.*Flow variables — node outputs (vars.<nodeId>.<key>), get_record results, vars.previous (the pre-update row)BPMN process variables

`record` and bare field names are NOT available and fail the node loudly. Everywhere else on this platform record means "the record at event time" (flow conditions: the trigger snapshot; hook conditions: the stored record overlaid with the write's payload) — at an approval node that phrase is ambiguous between two different times, so you must say which one: current.x or trigger.x. Do not carry the record.x habit over from conditions.

Result contract: a user-id string, a CSV string, or an array of ids. An empty result (present-but-empty field/variable) triggers onEmptyApprovers. A missing key (vars.never_written) is a loud error, never a silent empty slate — guard genuinely-optional inputs explicitly, e.g. has(vars.picked) ? vars.picked : [].

typescript
// ① Route on a field an EARLIER approver filled in mid-flow (live value):
{ type: 'expression', value: cel`current.co_review_departments`, resolveAs: 'department' }

// ② The previous approval node's decision outputs pick this node's approvers:
{ type: 'expression', value: cel`vars.lead_review.next_reviewers` }

// ③ Dynamic co-sign (会签): expression yields department ids; resolveAs expands
//    each into its members, and with behavior: 'per_group' EACH department is
//    its own sign-off group:
{
  approvers: [{ type: 'expression', value: cel`current.picked_departments`, resolveAs: 'department' }],
  behavior: 'per_group',
  onEmptyApprovers: 'fail',
}

The full "previous approver picks the next step's approvers" loop, end to end (the shipped showcase_dynamic_approval flow in the showcase app is this shape):

<!-- os:check -->

typescript
import { defineFlow } from '@objectstack/spec';

export const DynamicApprovalFlow = defineFlow({
  name: 'dynamic_approval',
  label: 'Dynamic Approval',
  type: 'autolaunched',
  status: 'active',
  nodes: [
    {
      id: 'start', type: 'start', label: 'On Submit',
      config: { objectName: 'expense', triggerType: 'record-after-update', condition: "status == 'submitted'" },
    },
    {
      // Node A declares what a decision may hand to the flow. The TYPED
      // declaration renders a multi-select sys_user picker in the decision
      // dialog; the lead approves with outputs:
      //   POST …/approve { outputs: { next_reviewers: ['u2', 'u3'] } }
      // `required: true` is enforced by the runtime on APPROVE (never on
      // reject) — node B below has nobody to route to without it.
      id: 'lead_review', type: 'approval', label: 'Lead Review',
      config: {
        approvers: [{ type: 'org_membership_level', value: 'owner' }],
        decisionOutputs: [{ key: 'next_reviewers', label: 'Next Reviewers', type: 'user', multiple: true, required: true }],
      },
    },
    {
      // Node B resolves them at entry from the lead's decision outputs.
      id: 'co_sign', type: 'approval', label: 'Co-sign',
      config: {
        approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }],
        behavior: 'unanimous',
        onEmptyApprovers: 'fail',
      },
    },
    { id: 'approved', type: 'end', label: 'Approved' },
    { id: 'rejected', type: 'end', label: 'Rejected' },
  ],
  edges: [
    { id: 'e1', source: 'start', target: 'lead_review' },
    { id: 'e2', source: 'lead_review', target: 'co_sign', label: 'approve' },
    { id: 'e3', source: 'lead_review', target: 'rejected', label: 'reject' },
    { id: 'e4', source: 'co_sign', target: 'approved', label: 'approve' },
    { id: 'e5', source: 'co_sign', target: 'rejected', label: 'reject' },
  ],
});

Time-word cheat sheet across surfaces (do not mix them up):

SurfaceEvent-time recordPre-event recordLive record
Flow condition / {…} templaterecord (trigger snapshot)previous— (use a get_record node)
Approval expression approvertrigger.*vars.previouscurrent.*

Object-hook ctx is a different vocabulary — see objectstack-data references/data-hooks.md.

Node Config (ApprovalNodeConfigSchema)

FieldPurpose
approversWho may act (≥ 1 — see Approver Types above). Each approver may carry an optional `group` label (e.g. { type: 'position', value: 'auditor', group: 'finance' }) — with behavior: 'per_group', approvers sharing a label form one group; unlabelled approvers each form their own
behaviorfirst_response (first approver decides), unanimous (all must approve), quorum (minApprovals of N — M-of-N collective sign-off), or per_group (EACH approver group must reach minApprovals — one-from-each-group sign-off, 会签). In every mode a single rejection finalizes the node as rejected. Default first_response
minApprovalsApprovals required — total for quorum, per group for per_group. Omitted ⇒ ALL resolvable approvers under quorum, 1 per group; clamped at runtime so a misconfiguration can never deadlock
lockRecordLock the triggering record from edits while pending. Default true
approvalStatusFieldBusiness-object field to mirror pending/approved/rejected/recalled onto (should be readonly)
onEmptyApproversWhat an EMPTY resolved slate does: admin_rescue (default — request opens, only a privileged admin can act via Reassign; never waves through, never kills the run), fail (node fails — treat an empty slate as a config bug), auto_approve (skip the request, continue down approve with output.autoApproved = true — opt-in because it silently waves the record through). Declare it explicitly on any node with an expression approver (linted)
decisionOutputsDecision outputs a decision may carry (author declares, approvers fill values). Entries are bare keys (free-text input) or typed declarations `{ key, label?, type: 'text'\'user'\'department'\'position'\'team', multiple?, required? } — a typed entry renders the matching record picker in the decision dialog (multiple collects an id array). Accepted outputs resume the run as <nodeId>.<key> variables; undeclared keys reject the decision; decision/requestId` reserved
escalationOptional per-node SLA — `{ enabled, timeoutHours, action: reassign\auto_approve\auto_reject\notify, escalateTo?, notifySubmitter }. timeoutHours is **calendar (wall-clock) hours** — nights, weekends and holidays count; the platform ships no business-hours calendar. escalateTo is a **position machine name** (expanded to its holders via sysuserposition, ADR-0090 D3) or a specific user id — never a membership tier. reassign without escalateTo` degrades to notify (linted)
maxRevisionsADR-0044 — max send-backs-for-revision per run before auto-reject. Default 3; 0 disables send-back. Only meaningful when the node has a revise out-edge

Branching, side-effects & rejection

These are wired on the graph, not in node config:

  • Conditional step — put a decision node before the Approval node, or a

condition on the edge entering it (the old per-step entryCriteria).

  • On approve / on reject — wire downstream nodes (update_record,

http, a notify node, …) to the approve / reject out-edge.

  • Roll back on reject — route the reject edge as a back-edge to an

earlier node so the submitter can revise (the old back_to_previous).

  • Send back for revision (ADR-0044) — distinct from a plain reject: a

revise out-edge into an `approval_revise` window, closed by a type: 'back' resubmit edge. See Send-back for revision above.

  • Hard reject — route the reject edge to an end node (the old

reject_process).

Approval Best Practices

  1. Gate entry on the edge (condition into the Approval node) so the flow

only pauses for records that actually need sign-off.

  1. Set `approvalStatusField` to mirror status onto the row — views and

formulas can then filter on it without joining sys_approval_request.

  1. Keep `lockRecord: true` unless you have a strong reason to allow

edits while pending — otherwise approvers chase a moving target.

  1. Model rejection as a visible branch — a back-edge to revise, or an end

node to terminate. The path is on the diagram, not hidden in config.

  1. Notify from downstream nodes wired to the approve / reject edges

rather than expecting the node to send mail itself.


Triggers — Event-Driven Automation

A record_change flow fires automatically on a data event. There is no standalone trigger object and no top-level `trigger` / `event` key — the binding lives entirely in the flow's `start` node `config`, which the automation engine parses (resolveTriggerBinding) and wires to the matching ObjectQL lifecycle hook.

Prerequisite — declare the capabilities your nodes need

Metadata registers without its token; the surface just never runs — two of them silently:

| requires token | Turns on | Absent ⇒ | |:--|:--|:--| | automation | the flow engine + node executors | flows never execute | | triggers | record-* / schedule / api start bindings | flows register, never fire | | job | cron cadence + the timeRelative sweep | scheduled flows never launch | | queue | the inbound-webhook consumer | inbound POST answers 503 | | approvals | approval / approval_revise (plugin-approvals) | no executor for the node type | | messaging | notify delivery to inbox (sys_inbox_message) | silent: success with skipped: true | | webhooks | defineStack({ webhooks }) outbound registrations | nothing delivered outbound |

typescript
defineStack({
  // …
  requires: ['automation', 'triggers', 'job', 'queue', 'approvals', 'messaging'],
});

Inbound webhook (api) triggers (ADR-0041 Tier 1)

An api flow can be bound to an inbound HTTP endpoint: POST /api/v1/automation/hooks/:flowName/:hookId. Configure it on the start node `config` (the start config is a free-form record, so these keys are read at runtime, not Zod-validated):

config keyPurpose
hookIdURL path token (default 'default'). Rotate it to revoke a leaked endpoint
secretHMAC-SHA256 shared secret. Strongly recommended — without it unsigned posts are accepted and a warning is logged
  • Signature: sender sends x-objectstack-signature: sha256=<hex> (GitHub/Stripe style).
  • Idempotency: x-idempotency-key dedupes retries — author the flow to be idempotent (delivery is at-least-once).
  • Queue-backed: the endpoint ACKs 202 and enqueues; the flow runs on the consumer, never in-band. Requires the queue service (see prerequisite).
  • The JSON body surfaces to the flow as the trigger record (record.* / bare fields) plus params.

Trigger Types (start-node config.triggerType)

triggerTypeFiresObjectQL hook
record-before-createbefore insert (can modify/reject)beforeInsert
record-after-createafter insertafterInsert
record-before-updatebefore updatebeforeUpdate
record-after-updateafter updateafterUpdate
record-before-deletebefore deletebeforeDelete
record-after-deleteafter deleteafterDelete
record-before-write / record-after-writecreate OR update — one flow, both eventsboth insert + update hooks

Trigger Configuration — on the start node

The binding is the START NODE — this is that node, not a whole flow (a flow also owns label, type, nodes and the edges FlowSchema requires):

typescript
{
  id: 'start',
  type: 'start',
  label: 'On Case Escalated',
  config: {
    objectName: 'support_case',
    triggerType: 'record-after-update',
    // bare CEL; gates whether the flow launches on the event
    condition: "previous.status != 'escalated' && record.status == 'escalated'",
  },
}
Prefer `record-after-** unless you must modify or reject the record; **guard** one that writes its own object, or it re-triggers itself. A record-before-*` flow that throws silently blocks the write — give it a user-facing message.
`previous` and `record` are the CEL variables available in update triggers — previous.x is the value before the change, record.x is the value after. See objectstack-formula.

Time-relative triggers — scheduled per-record date sweep

Don't express "act N days before/after a date" (renewal reminders, "expiring soon", overdue sweeps) as a record_change flow gated on date-equality (end_date == daysFromNow(60)) — that predicate is only evaluated when the record happens to change, so unattended it almost never fires. Use a declarative time-relative trigger: a schedule-type flow whose start node carries a `timeRelative` descriptor is swept on a schedule (daily by default) and launched once per record whose date field falls in the window. The record is on the context, so the start condition and {record.*} interpolation work exactly as for a record-change flow — and because the window is evaluated every day, a threshold is never missed.

typescript
{
  name: 'renewal_alert',
  label: 'Renewal Alert',
  type: 'schedule',
  runAs: 'system',              // a sweep has no trigger user — elevate explicitly
  nodes: [
    {
      id: 'start', type: 'start', label: 'Daily Sweep',
      config: {
        timeRelative: {
          object: 'contracts',
          dateField: 'end_date',
          offsetDays: [60, 30, 7],   // fire exactly at T-60 / T-30 / T-7
          // — or — withinDays: 30    // "expiring within 30 days" (negative = overdue lookback)
          filter: { status: 'active' },  // optional, ANDed with the date window
          // maxRecords: 1000            // optional per-sweep cap (default 1000)
        },
        // Optional sweep cadence; omit for daily 08:00 UTC. Plain shape only:
        // schedule: { type: 'cron', expression: '0 8 * * *' }
      },
    },
    // …downstream nodes (notify, update_record, …)
  ],
  edges: [ /* start → downstream */ ],
}

Date EQUALITY never matches, so a hand-rolled sweep filters windows, not days: a date field carries a time component, so field == daysFromNow(N) (or { $in: [...] }) compares two differently-timed timestamps and silently returns nothing (build warns flow-date-equality-filter). Tier each threshold as a one-day window ($gte/$lt):

ts
filter: { status: 'active', $or: [
  { end_date: { $gte: '{TODAY() + 7}',  $lt: '{TODAY() + 8}'  } },
  { end_date: { $gte: '{TODAY() + 30}', $lt: '{TODAY() + 31}' } },
  { end_date: { $gte: '{TODAY() + 60}', $lt: '{TODAY() + 61}' } },
] }

Abutting windows tile the timeline, so each record matches exactly one tier — fires once, idempotent, no guard field. Use {TODAY() + N} template tokens in CRUD-node filter values; a cel\…\` envelope is not evaluated there and would be compared as a literal object. For "days remaining" in a message, use daysBetween(today(), record.end_date)`.

Exactly one of offsetDays (discrete T-minus days) or withinDays (a range; negative = overdue) is required. Ships in @objectstack/trigger-schedule — needs requires: ['automation', 'triggers'] plus `'job'` (the sweep cadence runs on the job service). Full descriptor schema: node_modules/@objectstack/spec/src/automation/time-relative-trigger.zod.ts.


CRM Automation Blueprint

For enterprise automation design, align with this CRM-style structure:

| Automation Type | Typical Location | Pattern | |:--|:--|:--| | Screen flow | src/flows/*.flow.ts | Use explicit variables, node graph (nodes + edges), and decision branches | | Approval flow | src/flows/*.flow.ts | A flow with approval node(s); set approvers / behavior / lockRecord / approvalStatusField in node config, branch on approve / reject edges | | Flow registry | src/flows/index.ts | Export allFlows: Flow[] and register centrally in defineStack({ flows }) |

Default approach for metadata apps: model business lifecycle in Flow/Approval metadata first; reserve custom code for edge-case integrations.


Verify your work

A bare ref (status == 'open') resolves — the engine flattens the record's fields into scope — so a typo there is only an advisory did-you-mean. record.status stays canonical, and os validate errors on an unknown record.<field>:

bash
os validate     # CEL/predicate validation (record.<field> existence) + schema
# or: os build  # the same gates, plus emits dist/

It runs the ADR-0032 gate over every condition, edge guard, validation and sharing rule, exiting non-zero. In a scaffolded project: npm run validate.


References

The send-back shape has a graded eval at evals/approvals/test-revise-loop.md.

See references/_index.md for the full list of Zod schemas (with one-line descriptions) — pointers into node_modules/@objectstack/spec/src/. Always Read the source for exact field shapes; do not rely on memory of property names.

同じリポジトリから

関連する Skills

すべての Skills
objectstack-ai
コミュニティ

objectstack-data

Design ObjectStack data schemas — objects, fields, field conditional rules, relationships, validations, indexes, lifecycle hooks, permissions, row-level security, data lifecycle retention/TTL/rotation, metadata protection locks, and external / federated datasources (defineDatasource) — and the seeds (defineSeed()) that load fixtures and reference data alongside them. Use when the user is creating or modifying .object.ts files or src/data/.ts seed modules, picking field types, modelling relationships, writing beforeInsert/afterUpdate hooks, configuring per-object access control, pointing an object at an existing external database, or authoring bootstrap / demo data. Use for visibleWhen / readonlyWhen / requiredWhen rules that belong on fields. Do not use for querying data (see objectstack-query) or for plugin / kernel hooks (see objectstack-platform). CEL expressions in formulas / validations / sharing rules / dynamic seed values: load objectstack-formula alongside.

導入数
1
GitHub Stars
59
更新日
9月17日
objectstack-ai
コミュニティ

objectstack-i18n

Author ObjectStack translation bundles — object/field labels, view text, app navigation strings, automation messages — and configure locale fallback, coverage reporting, and the source layout. Use when the user is adding .translation.ts files, authoring a translation metadata item in the Studio or through the metadata API, shipping a generated bundle from a package or plugin, wiring a new locale, or resolving missing-translation warnings. Do not use for general i18n library questions unrelated to ObjectStack bundles.

導入数
1
GitHub Stars
59
更新日
9月17日
objectstack-ai
コミュニティ

objectstack-query

Construct ObjectQL queries — filters, sorting, pagination, aggregation, relation expansion, and full-text search. Use when the user is writing a query DSL expression or picking a pagination strategy. Do not use for defining objects / fields / relationships (see objectstack-data), for designing the API endpoint that exposes a query (see objectstack-api), or for a list view's filter rules / dashboard datasets (see objectstack-ui).

導入数
1
GitHub Stars
59
更新日
9月17日
objectstack-ai
コミュニティ

objectstack-upgrade

Upgrade an ObjectStack metadata project across a protocol major — run the deterministic conversion chain, then work the semantic residue the chain cannot express (intent choices, custom code on retired APIs, stale prose) to a decision with the project's owner, and finish with a green validate plus a human-readable upgrade report. Use when a project is on an older protocol major and must move to the current one, when @objectstack/spec was bumped across a major and metadata or code stopped parsing, when a parse or tsc error quotes a [REMOVED] prescription, or when asked to "upgrade to v17" / "升级到 v17" / "一键升级元数据项目". Do not use to author new metadata (the domain skills cover that), or to reconcile physical database drift (that is objectstack-platform's os migrate plan / os migrate apply).

導入数
1
GitHub Stars
59
更新日
9月17日