Status: Accepted (2026-05-31) — M1–M3 implemented; M4 framework-side complete (designer config-forms tracked in ../objectui); M5 dropped (Workflow-Rule→Flow compiler removed per ADR-0019; approval execution converged via the approval Flow node, not a compiler). The workflow_rule authoring paradigm is retired.
Deciders: ObjectStack Protocol Architects
Builds on: ADR-0005 (one Zod source of truth per metadata type), ADR-0012 (generalized outbox in service-messaging)
Consumers: @objectstack/spec (automation/), @objectstack/services/service-automation, @objectstack/plugins/plugin-approvals, @objectstack/plugins/plugin-webhooks → service-messaging, every plugin that registers a node executor, ../objectui (plugin-workflow designer)
The platform ships three authoring paradigms for business logic — visual Flow (canvas), declarative Workflow Rules, and Approval Processes — plus a graphical designer in ../objectui. Each one independently hardcodes a closed list of the node/action types it understands. The four lists do not agree, and none of them is the runtime's actual extension point.
The result: the same outbound concept ("call an HTTP endpoint" / "notify a human") appears under five different names across the codebase, the designer can paint nodes the engine cannot execute, and a plugin that registers a brand-new node type is silently rejected by spec validation and never appears in the palette — directly defeating the plugin mechanism this platform is built on.
This ADR proposes one registry-backed node/action contract that all three paradigms and the designer consume. The runtime registry is already open (registerNodeExecutor(type: string)); we make the protocol and the designer consumers of it instead of parallel closed enums. We also consolidate the duplicated outbound verbs onto two shared executors (http / notify) backed by the ADR-0012 outbox, which closes the reliability gap where http_request today is a bare fetch() with no retry.
This is not an accident to be "fixed" by collapsing everything into one engine. Salesforce ships Flow + Workflow Rules + Approval Processes + Process Builder as distinct paradigms for distinct personas, and ObjectStack mirrors that. Multiple authoring paradigms are correct. What is not correct is that each paradigm reinvents the execution vocabulary beneath it.
| Subsystem | Protocol | Runtime | Node/action vocabulary | "outbound" verbs |
|---|---|---|---|---|
| Flow (canvas) | automation/flow.zod.ts → FlowNodeAction (closed z.enum) |
✅ service-automation (partial) |
start, end, decision, assignment, loop, get/create/update/delete_record, http_request, script, screen, wait, subflow, connector_action, parallel_gateway, join_gateway, boundary_event | http_request |
| Workflow Rules | automation/workflow.zod.ts → WorkflowAction (closed discriminatedUnion) |
❌ none — spec-only | field_update, email_alert, http_call, task_creation, push_notification, custom_script, connector_action | http_call, email_alert, push_notification |
| Approval Process | automation/approval.zod.ts → ApprovalActionType (closed z.enum) |
✅ plugin-approvals (partial) |
field_update, email_alert, webhook, script, connector_action, inbox_notify | webhook, inbox_notify, email_alert |
| Designer | ../objectui types/workflow.ts → FlowNodeType (closed union) |
— (UI only) | task, user_task, service_task, script_task, approval, condition, parallel_gateway, join_gateway, boundary_event, delay, notification, webhook | notification, webhook |
| (planned) Notification | ADR-0012 | service-messaging |
— | notify |
The same "send something outbound" concept has five names: http_request, http_call, webhook, notification, notify. "Notify a human" has four: inbox_notify, push_notification, notification, email_alert.
-
Plugin extensibility is broken at the protocol layer. The runtime engine is already an open registry —
registerNodeExecutor(executor)keys on a freestringtype, andgetRegisteredNodeTypes()enumerates them (service-automation/src/engine.ts). ButregisterFlow()runsFlowSchema.parse(definition), andFlowNodeSchema.typeis the closedFlowNodeActionenum. A plugin that registers a new executor type produces flows that fail spec validation. The open runtime is locked shut by a closed protocol. -
The designer paints what the engine can't run.
TOOLBAR_NODE_TYPESinplugin-workflow/src/FlowDesigner.tsxis hardcoded, and itsFlowNodeTypevocabulary (BPMN-flavored:task/service_task/notification/webhook) matches neitherflow.zod.tsnor the runtime executors (decision,http_request,create_record, …). Anotificationnode dragged onto the canvas has no executor behind it. A plugin-registered node type never appears in the palette. -
The reliability machinery is built once and reused nowhere.
plugin-webhookshas the durable outbox / exponential retry / cluster-lock / dead-letter (per ADR-0012 §"What plugin-webhooks already provides"). Yet Flow'shttp_requestexecutor is a barefetch()with no retry, no idempotency, no outbox (service-automation/src/builtin/http-nodes.ts). Workflow Rules'http_callhas no runtime at all. Approval'swebhookaction is a third implementation. Four would-be HTTP callers, zero sharing the one reliable substrate.
automation/node-executor.zod.ts already defines NodeExecutorDescriptor — id, name, nodeTypes, version, supportsPause, supportsCancellation, supportsRetry, configSchemaRef. This is exactly the "plugin contributes a node type, with metadata" shape. Today it is an orphan: nothing registers one, and the designer does not read it. The fix is largely to promote and wire what is already specified, not to invent a new abstraction.
Adopt a single registry-backed node/action contract. The runtime executor registry is the source of truth; the protocol and the designer become its consumers. Concretely:
- Protocol stops hardcoding. Promote
NodeExecutorDescriptorto the canonical, cross-paradigm Action descriptor. Replace the three closed enums with "built-in descriptors + open extension".FlowNodeSchema.typebecomes a validatedstring(checked against the registry at parse time), not a frozen enum. - Runtime publishes descriptors and consolidates outbound verbs. Each executor upgrades from
{ type, execute }to also publish a descriptor. The five outbound names collapse onto onehttp(callout) executor and onenotifyexecutor, the latter backed by the ADR-0012service-messagingoutbox. Flow, Workflow Rules, and Approval all invoke the same two. - Designer renders from the registry. The palette is populated from descriptors (label / icon / category / config schema), not a hardcoded list. The BPMN shape vocabulary is demoted to a presentation/
bpmn-interopconcern, not the authoring vocabulary. - Workflow Rules compile to Flow. Do not build a fourth runtime. The declarative rule becomes a simplified authoring view that compiles down to Flow nodes over the same executor registry.
This is not "change the protocol or the frontend" — both change, in that order, because the deep fix is the contract. Fixing the frontend first would re-freeze today's wrong vocabulary.
Extend the existing NodeExecutorDescriptor (automation/node-executor.zod.ts) with the metadata a palette and a config form need, and the capability flags the dispatcher needs:
// automation/node-executor.zod.ts (extended)
export const ActionDescriptorSchema = z.object({
// ── identity ───────────────────────────────────────────────
type: z.string(), // 'http' | 'notify' | 'create_record' | plugin-defined
version: z.string(), // semver of the executor
name: z.string(), // human label (i18n key)
description: z.string().optional(),
// ── palette presentation (NEW — was missing) ──────────────
icon: z.string().optional(), // icon id resolved by the designer
category: z.enum(['logic','data','io','human','control','custom']).default('custom'),
paradigms: z.array(z.enum(['flow','workflow_rule','approval']))
.default(['flow']), // which authoring surfaces may offer this action
// ── config contract (NEW — drives Studio form + parse validation) ──
configSchema: z.unknown().optional(), // JSON Schema (compiled from the executor's Zod)
// ── capabilities (existing + reliability) ─────────────────
supportsPause: z.boolean().default(false),
supportsCancellation: z.boolean().default(false),
supportsRetry: z.boolean().default(true),
needsOutbox: z.boolean().default(false), // true → dispatch via service-messaging
isAsync: z.boolean().default(false), // request/response that suspends the flow
});One Zod source per metadata type (Prime Directive 8 / ADR-0005): this is that one source for "what a node/action is".
FlowNodeAction,WorkflowAction, andApprovalActionTypestop being independent truths and become seed descriptor sets registered at boot.
// automation/flow.zod.ts
// BEFORE: type: FlowNodeAction (closed enum — rejects plugin types)
// AFTER:
export const FlowNodeSchema = lazySchema(() => z.object({
id: z.string(),
type: z.string().describe('Action type — validated against the action registry at registerFlow()'),
label: z.string(),
config: z.record(z.string(), z.unknown()).optional(),
// …unchanged…
}));FlowNodeAction is retained as an exported const array of built-in type ids (documentation + the seed set), but it no longer gates type. Validation moves from "is it in this enum" to "is it a registered action type, and does config satisfy that action's configSchema" — performed in registerFlow() against the live registry. This is the only way a plugin-registered node can ever be a legal flow.
NodeExecutor gains an optional descriptor:
export interface NodeExecutor {
readonly type: string;
readonly descriptor?: ActionDescriptor; // NEW — published into the registry
execute(node, variables, context): Promise<NodeExecutionResult>;
}AutomationEngine already exposes getRegisteredNodeTypes(); add getActionDescriptors(): ActionDescriptor[]. This single method backs both flow validation (server side) and the designer palette (an API: GET /api/v1/automation/actions).
Two executors replace the five names:
| New executor | Replaces | Backed by | Notes |
|---|---|---|---|
http |
Flow http_request, Workflow-Rule http_call, Approval webhook |
service-messaging outbox (ADR-0012) |
needsOutbox: true, supportsRetry: true. Sync request/response variant sets isAsync: true and suspends the flow via the existing wait pause/resume seam. |
notify |
Workflow-Rule push_notification, Approval inbox_notify, designer notification, ADR-0012 notify |
service-messaging channels |
Channel selection (inbox/email/push) handled by the notification platform, not by N node types. |
Both are registered once and offered to all three paradigms via descriptor.paradigms. This is where the http_request-has-no-retry gap closes: HTTP becomes an outbox-backed executor, so every paradigm inherits retry / idempotency / dead-letter for free, exactly as ADR-0012 intended for notify.
Migration aliases keep old flows valid: the registry registers http_request / http_call / webhook as deprecated aliases of http for one major version (same pattern ADR-0012 uses for plugin-email).
In ../objectui plugin-workflow:
- Delete the hardcoded
FlowNodeTypeunion andTOOLBAR_NODE_TYPES. - Fetch
GET /api/v1/automation/actions; render the palette grouped bydescriptor.category, labeled byname/icon. - Generate each node's config form from
descriptor.configSchema(the platform already does JSON-Schema-driven form generation elsewhere). - The BPMN node shapes (
task/service_task/gateways/boundary_event) become a rendering/export concern owned bybpmn-interop, decoupled from the authoring action list. A plugin node renders with its declared icon; it does not need a bespoke BPMN shape.
WorkflowAction (declarative) compiles to a small Flow graph over the shared executors:
on_update(condition) ──► [ http ] ──► [ notify ] ──► [ field_update→update_record ] ──► end
No fourth engine. Workflow Rules stays a simplified authoring view for business users; execution, reliability, and observability are Flow's. This matches the industry trajectory (Salesforce is retiring Workflow Rules in favor of Flow) and means every fix to the executor registry benefits all three surfaces at once.
| Step | Change | Compatibility |
|---|---|---|
| M1 ✅ | Add canonical ActionDescriptorSchema (+ defineActionDescriptor) alongside the legacy NodeExecutorDescriptor; FlowNodeSchema.type → validated string (with FlowNodeAction/FLOW_BUILTIN_NODE_TYPES retained as the seed set); registerFlow() soft-validates node types against the live registry (warn, don't hard-fail). |
Shipped. Existing flows keep validating (built-in types seed-registered); plugin-registered node types are now legal flow nodes. |
| M2 (partial ✅) | Built-in nodes (logic/crud/http/screen) publish descriptors and are folded into the core AutomationServicePlugin (seeded via installBuiltinNodes()), so automation is a self-contained capability — no companion node-pack plugins, no extras in the capability loader. connector_action dropped from the baseline (an integration concern needing a connector registry the platform doesn't ship; left to the integration layer / marketplace plugins via the still-open registerNodeExecutor()). Descriptors tagged source: 'builtin' vs 'plugin'. AutomationEngine.getActionDescriptors()/getActionDescriptor() + optional IAutomationService.getActionDescriptors(). GET /api/v1/automation/actions shipped via HttpDispatcher.handleAutomation (?paradigm/?source/?category filters; AutomationActionsResponseSchema in spec; declarative entry in DEFAULT_AUTOMATION_ROUTES). |
Additive; built-in flows keep working from the core plugin alone. Hosts that hand-assembled the four *NodesPlugin classes drop them (the classes are gone). |
| M3 ✅ | Canonical http + notify executors backed by service-messaging (HTTP outbox sys_http_delivery / notification outbox); http_request/http_call/webhook registered as deprecated aliases (registerNodeAlias, needsOutbox). |
Shipped. Old node types keep running via alias; degrade to inline when no outbox is wired. |
| M4 (framework ✅) | Hardcoded FlowNodeType removed (M1 turned FlowNodeSchema.type into a validated string seeded from FLOW_BUILTIN_NODE_TYPES); GET /automation/actions drives the palette. Remaining: designer config-forms rendered from descriptor.configSchema — tracked in ../objectui. |
Framework carries no closed node enum; old saved graphs still load. |
Workflow-Rule → Flow compiler removed — there is no declarative Workflow-Rule authoring type to compile (Workflow Rules removed in #1398; workflow reclaimed for state machines, ADR-0020). The workflow_rule paradigm tag is retired from ActionParadigmSchema and all descriptors. Approval execution convergence is delivered by ADR-0019 (approval is a durable-pause Flow node; side-effects run on the shared http/notify executors), not a compiler. |
No legacy to migrate (greenfield). |
Positive
- A plugin that registers an executor is automatically a legal flow node and appears in the designer palette — the plugin mechanism finally works end to end.
- One reliable HTTP path and one notify path; retry/outbox/dead-letter built once, inherited everywhere (closes the
http_requestreliability gap and folds in ADR-0012'snotify). - The four vocabularies converge to one registry; "five names for one concept" goes away.
- Studio observability (ADR-0012 §13 Deliveries/Dead-letter) automatically covers Flow and Approval HTTP/notify, not just notifications.
Negative / risks
- Parse-time validation now depends on the live registry. A flow authored against a plugin that is later disabled fails validation. Mitigation: validate with a "known types" snapshot + warn (not hard-fail) on unknown types, mirroring
flow.form.tserrorHandling (fail | retry | continue). - Designer rewrite touches
../objectui(separate repo / release train). Phased: registry API ships first; the palette can read it before the hardcoded list is deleted. - Async HTTP (request/response) needs the pause/resume seam. The
waitexecutor /node-executor.zod.tsresume payload already exists; thehttpexecutor'sisAsyncpath reuses it rather than inventing suspension. - JSON Schema ⇆ Zod round-trip for
configSchema. Compile Zod → JSON Schema at descriptor publish time (build step), don't hand-maintain both.
- Does
connector_action(the one verb already present in all three paradigms) become the general extension action, withhttp/notifyas well-known specializations — or stay peer-level? Leaning: keep peer-level;connector_actiontargets a registered connector,httpis raw. - Should
screen/user_task(human-input nodes) carry their own descriptor category (human) that the runtime treats as always-isAsync? Likely yes. - Where does the action registry live for cross-environment consistency — is it per-environment (a plugin enabled in env A but not B yields different palettes)? Tie to the package/environment model (ADR-0006).
Status of this addendum: implemented. This re-scopes the §Migration M2 note and resolves §Open-questions #1. The baseline registry +
connector_actionexecutor ship inservice-automation, with@objectstack/connector-restas the first concrete connector plugin.
connector_action is promoted to a built-in (source: 'builtin') baseline node, the generic-dispatch counterpart to http_request:
- where
http_requestcalls any raw URL,connector_actioninvokes any registered connector's declared action; - the engine ships the dispatch node plus an initially-empty connector registry (
registerConnector/resolveConnectorAction/getRegisteredConnectors); - concrete connectors (
@objectstack/connector-rest,connector-slack,connector-salesforce, …) remain plugins that populate the registry at runtime.
This is the mechanism/policy split: the mechanism (registry + dispatch node) is baseline; the concrete integrations (and their credentials/lifecycle) are not. It mirrors the ADR-0015 datasource split — federation contract is in the open framework, managed connection lifecycle lives outside it.
M2 dropped connector_action because it would need "a connector registry the platform doesn't ship." That is circular: the registry is the missing piece, and an empty registry is zero-dependency and zero-cost. The protocol already commits to the node — connector_action is in FLOW_BUILTIN_NODE_TYPES and connectorConfig {connectorId, actionId, input} is already a FlowNode field — but ships no executor, so any flow referencing it fails at execution. Shipping the empty registry + dispatch executor closes that spec/runtime gap without pulling any concrete integration into the core.
The leaning ("keep peer-level") is overturned for the dispatch direction, kept for the verbs: connector_action does become the general connector-extension action, while http/notify stay peer-level raw verbs (not specializations of it). http_request calls a URL with no registration; connector_action calls a registered, named capability. Both are baseline; neither is implemented in terms of the other.
Because the registry starts empty, a flow that references a connector no plugin has registered fails that step with a clear error (no handler for '<id>.<action>' — is the connector plugin registered?) rather than failing to register the flow — the same fail-soft posture http_request takes on a bad URL.
Managed credentials/secret vault, OAuth2 token refresh, multi-tenant connection lifecycle, and a connector marketplace are not part of this mechanism — they are the enterprise tier, on the ADR-0015 precedent. The open framework ships the contract + dispatch + an in-process registry only.
-
AutomationEngine: connector registry (registerConnector/unregisterConnector/resolveConnectorAction/getRegisteredConnectors) +ConnectorActionHandler/ConnectorActionContexttypes. -
builtin/connector-nodes.ts:connector_actionexecutor + descriptor (category: 'io',source: 'builtin',paradigms: ['flow','approval']), wired intoinstallBuiltinNodes(). The core plugin now seeds 11 baseline node types (was 10). - First concrete plugin
@objectstack/connector-rest(the reference connector) validating the registry —requestaction, static auth (none/api-key/basic/bearer), no OAuth2 refresh. - Tests: baseline dispatch (fake connector) + REST plugin auth-header injection + end-to-end kernel boot (both plugins →
connector_actionflow → REST handler).