Skip to content

Commit c94827d

Browse files
feat(automation): approval as a durable-pause flow node (ADR-0019 P1–P3) (#1407)
Collapse approval onto the one flow engine: an Approval node suspends the run on entry and resumes on a human decision, instead of a parallel approval engine. Additive and non-breaking — the standalone process engine is left intact for the migration window (A4/A5 deletions follow up). P1 — engine durable suspend/resume - FlowSuspendSignal thrown from executeNode, caught in execute()/resume(), persisted to an in-memory suspendedRuns map. - AutomationResult gains status ('completed'|'paused'|'failed') + runId; IAutomationService gains optional resume(runId, signal?) and listSuspendedRuns(). Resume selects the out-edge whose label matches ResumeSignal.branchLabel. $runId injected into flow variables. - screen node opts into pause via config.waitForInput. P2 — Approval node contract (spec) - APPROVAL_NODE_TYPE, ApprovalDecision, APPROVAL_BRANCH_LABELS, ApprovalNodeConfigSchema (approvers / behavior / lockRecord / escalation / approvalStatusField lowered into node config). - Deprecate ApprovalProcessSchema + ApprovalActionType via JSDoc. P3 — plugin-approvals node provider (additive bridge) - Register the `approval` node (supportsPause, category:human) via the ADR-0018 registry: on entry open a sys_approval_request (reusing approver resolution / audit / lock / status mirror) and suspend; decideApprovalNode finalizes and resumes down approve/reject edge. - sys_approval_request gains flow_run_id / flow_node_id / node_config_json. Tests: spec 6605, service-automation 79, plugin-approvals 41 — all green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent 1888f81 commit c94827d

14 files changed

Lines changed: 1332 additions & 9 deletions

File tree

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# ADR-0019: Collapse Approval into Flow — one engine, approval as a durable-pause node
2+
3+
**Status**: Proposed (2026-05-31)
4+
**Deciders**: ObjectStack Protocol Architects
5+
**Builds on**: [ADR-0018](./0018-unified-node-action-registry.md) (open action registry — approval becomes a consumer), [ADR-0009](./0009-execution-pinned-metadata.md) (execution pinning — reconcile to one mechanism), [ADR-0012](./0012-notification-platform.md) (outbox / `notify`), [ADR-0010](./0010-nl-to-flow-authoring.md) + [ADR-0011](./0011-actions-as-ai-tools.md) (AI authoring — the design center)
6+
**Revises**: ADR-0018's premise that Approval stays a separate paradigm with its own closed `ApprovalActionType` enum, and the "Workflow-Rule → Flow compiler" (M5) — both dropped here (greenfield, no legacy).
7+
**Consumers**: `@objectstack/spec` (`automation/approval.*`, `automation/flow.zod.ts`), `@objectstack/services/service-automation`, `@objectstack/plugins/plugin-approvals`, `@objectstack/platform-objects` (`audit/sys-approval-*`), `../objectui` (`plugin-workflow` designer)
8+
9+
---
10+
11+
## TL;DR
12+
13+
Approval is currently a **second execution engine** (`@objectstack/plugin-approvals`, ~1500 LOC) that runs *beside* Flow with its own action executor, its own execution pinning, and its own lifecycle. Its declarative `ApprovalProcessSchema` cannot express a graphical branch and cannot place an ordinary Flow node (e.g. an HTTP connector) between two approval steps, so admins must choose which tool to model in.
14+
15+
ADR-0018 opened the node/action registry; this ADR uses it to **collapse approval onto the one Flow engine**. Approval becomes a **first-class durable-pause node** contributed through the open registry (like the existing `screen` node), not a parallel engine and not engine core. The standalone `ApprovalProcessSchema` is **deprecated as an authoring type** — its step-sequencing dissolves into Flow graph edges, its per-step actions into downstream Flow nodes (over the ADR-0018 registry), and its approver/escalation/lock model into **node config**. The approval *runtime state* (`sys_approval_request` / `sys_approval_action`, lock, status mirror, approver resolution) is **kept** — it just stops carrying a second execution loop.
16+
17+
Because the future authoring path is **AI generates the Flow, the human previews the diagram and confirms**, a single composable IR (one Flow graph with rich nodes) beats a constrained side DSL: the human reviews one picture, and the AI targets one representation instead of choosing among DSLs with escape hatches.
18+
19+
## Context
20+
21+
### Greenfield — no migration constraint
22+
23+
The platform is not launched; there is no production approval data and no legacy automation to translate. This removes the usual reason to keep a deprecated model alive for compatibility, and it removes the reason for a Workflow-Rule → Flow compiler entirely (Workflow Rules were already removed in #1398, and `workflow` was reclaimed for state machines). **Migration is a code refactor, not a data migration.**
24+
25+
### Today: approval is a separate engine (the Salesforce pit, in our codebase)
26+
27+
ADR-0018 §Context argued — correctly — that *multiple authoring paradigms are fine; multiple execution vocabularies are not*. Approval is where that line is currently crossed at the **engine** level, not just the vocabulary level:
28+
29+
- `@objectstack/plugin-approvals` is ~1500 LOC of runtime: an 816-line `approval-service.ts` state machine, a **313-line parallel `action-executor.ts`**, 250-line lifecycle hooks, and a 128-line plugin.
30+
- The contract is explicit that this is a separate engine: [`spec/contracts/approval-service.ts:11`](../../packages/spec/src/contracts/approval-service.ts#L11)*"Sits on top of (but does not depend on) `IWorkflowService` … driven by humans rather than transition rules."*
31+
- The parallel `action-executor.ts` re-implements `field_update` / `inbox_notify` / `webhook` and carries the **same** `connector_action` / `script` / `email_alert` "unimplemented, logged + skipped" stubs that ADR-0018 set out to retire.
32+
- It has its **own** ADR-0009 execution pinning (`process_hash``getByHash`), parallel to Flow's.
33+
- It registers its own lifecycle hooks: `afterInsert` auto-trigger, `beforeUpdate` record-lock ([`plugin-approvals/src/lifecycle-hooks.ts`](../../packages/plugins/plugin-approvals/src/lifecycle-hooks.ts)).
34+
35+
This is precisely the failure mode Salesforce lived for years: Approval Process as a separate engine from Flow, never cleanly folded in, leaving admins to pick a tool and the platform to maintain two of everything.
36+
37+
### Why the declarative `ApprovalProcessSchema` hits a wall
38+
39+
[`automation/approval.zod.ts`](../../packages/spec/src/automation/approval.zod.ts) is a *linear* model: `steps[]`, per-step `approvers` / `behavior` / `rejectionBehavior`, and per-step `onApprove` / `onReject` actions drawn from a **closed** `ApprovalActionType` enum. Two concrete things it cannot do:
40+
41+
1. **No graphical branch.** A reviewer cannot see, or author, an arbitrary branch — only a step list plus `rejectionBehavior: back_to_previous`.
42+
2. **No mid-process Flow step.** You cannot place a connector / HTTP / decision node *between* approval step 2 and step 3; the only "between" available is the limited per-step action enum. Any real integration forces an escape out of the model.
43+
44+
The result is exactly the tool-choice tax: simple approvals go in the approval model, anything composite has to be rebuilt as a Flow.
45+
46+
### The design-center shift: AI generates, humans preview
47+
48+
The intended authoring path (ADR-0010 / ADR-0011) is **AI generates the automation; the human previews the design diagram and confirms it matches intent**. This changes what the representation must optimize for:
49+
50+
- The "fill a 30-second form vs. wire a 30-node graph" authoring-ergonomics argument for a constrained DSL **evaporates** — the human is not authoring either way; the AI is, and the human *reviews a diagram*.
51+
- Reviewing **one** unified Flow graph is easier than reviewing "a linear approval config *and* a Flow graph" side by side.
52+
- An AI targets **one composable IR** more reliably than it chooses among several constrained DSLs and their escape hatches.
53+
54+
So the design center now favors a single expressive Flow representation with a rich Approval node, not a separate approval DSL.
55+
56+
## Decision
57+
58+
Collapse approval onto the one Flow engine. The four sub-decisions:
59+
60+
### D1 — One execution engine; approval rides it as a durable-pause node
61+
62+
There is **one** execution loop: the Flow engine. The engine core owns a generic **durable-pause-and-resume** primitive — a node may suspend the run and resume on an external signal (timer, event, or a human decision). The `screen` node already uses this (`supportsPause` / `isAsync`); we formalize "resume on external signal" as the shared mechanism. Approval is a node that uses it. The parallel approval execution loop (`approval-service.ts`'s stepping + `action-executor.ts`) is removed.
63+
64+
> The "one engine" property — not "one state table" — is what avoids the Salesforce pit. The pit was two *execution loops*. Approval keeping its own state objects is correct and necessary (see "What must NOT be lost"); a second execution loop is not.
65+
66+
### D2 — Approval is a plugin that contributes a node, not engine core
67+
68+
The Approval node is registered through the **ADR-0018 open registry** (`registerNodeExecutor`), by a slimmed-down approval plugin — **not** baked into `service-automation` core. Rationale:
69+
70+
- It is the ADR-0018 thesis applied to ourselves: the engine is the substrate, capabilities are contributed nodes.
71+
- **Layering.** Approver resolution depends on the org / sharing model — `sys_team`, `sys_department` (recursive BFS), `sys_user.manager_id`, `sys_department_member` ([`plugin-approvals/src/approval-service.ts:175`](../../packages/plugins/plugin-approvals/src/approval-service.ts#L175)). The Flow engine core must **not** depend on the org model; the approval plugin may. So approval cannot live in core.
72+
- `service-automation` stays lean; approval becomes a well-behaved node provider that rides the engine instead of a parallel engine.
73+
74+
### D3 — Deprecate `ApprovalProcessSchema` as a top-level authoring type; re-home its concepts
75+
76+
`ApprovalProcessSchema` / `approval.form.ts` are deprecated as a standalone *authoring* metadata type. Nothing is thrown away — each concept moves:
77+
78+
| In `approval.zod.ts` today | Re-homed to |
79+
|:---|:---|
80+
| `steps[]` (sequence) | Multiple Approval nodes connected by Flow edges (sequence becomes a graph) |
81+
| `rejectionBehavior: back_to_previous` | A back-edge in the Flow graph (the branch is now visible) |
82+
| `onApprove` / `onReject` + `ApprovalActionType` enum | Downstream Flow nodes on the node's approve/reject outputs, over the ADR-0018 registry (**enum deleted**) |
83+
| `ApproverType` (user/role/team/department/manager/field/queue), `behavior` (unanimous/first_response), `escalation`, `lockRecord`, `approvalStatusField` | **Approval node config schema** (`configSchemaRef` per the descriptor) |
84+
85+
### D4 — Delete the parallel pieces
86+
87+
- `plugin-approvals/src/action-executor.ts` (313 LOC) — replaced by downstream Flow nodes + the ADR-0018 action registry.
88+
- `ApprovalActionType` enum and the dangling `connector_action` it still carries.
89+
- The Workflow-Rule → Flow compiler (ADR-0018 M5) and the `connector_action` remnants in `flow.zod.ts` — no legacy to migrate.
90+
- Reconcile to **one** ADR-0009 execution-pinning mechanism: the Flow definition is pinned; approval's separate `process_hash` pinning is retired.
91+
92+
### What must NOT be lost
93+
94+
The normalized **approval runtime state** is kept as first-class state owned by the approval plugin:
95+
96+
- `sys_approval_request` (current step, current approver, status, history pointer) and `sys_approval_action` (immutable audit) — a Flow-run log **cannot** answer "approvals pending on Alice > 3 days", drive a "my approvals" inbox, or serve recall / delegate. These need the normalized shape.
97+
- Record lock (`beforeUpdate` hook) + status mirror field; approver resolution (team / department BFS / manager / role / queue, ~200 LOC) — moved nearly verbatim under the node, not rewritten.
98+
- Approve / reject / **recall**, `unanimous` / `first_response`, and SLA escalation remain required capabilities of the Approval node — enumerated here so a naive "just use a pause node" refactor cannot silently drop them.
99+
100+
## Consequences
101+
102+
**Positive**
103+
- One execution engine — the Salesforce two-engine pit is closed in our own codebase.
104+
- One authoring surface — no admin tool-choice; approvals and integrations live in the same Flow.
105+
- Graphical branching and a connector node *between* approval steps both become trivial — the two concrete walls of `ApprovalProcessSchema` are gone.
106+
- One IR for AI to emit and a human to review; one action vocabulary (ADR-0018 registry) instead of a parallel enum.
107+
- Net deletion: the 313-LOC parallel `action-executor.ts`, `ApprovalActionType`, the M5 compiler, and the `connector_action` remnants.
108+
109+
**Cost / risk**
110+
- ~1500-LOC plugin refactor — but roughly half is *keep-and-re-home* (approver resolution ~200 LOC, state objects, lock hooks ~250 LOC), not rewrite. No data migration (greenfield).
111+
- The engine core must generalize durable-pause into "resume on external human decision"; today only `screen` exercises the pause path.
112+
- **Primary risk:** a refactor that degrades approval into a bare pause node and drops approver richness / escalation / recall / audit. Mitigated by enumerating these as required node capabilities (above) and by keeping the existing `approval-service.test.ts` / `phase-b.test.ts` behavioral suites green against the new node.
113+
114+
## Phased plan
115+
116+
Tracked separately from the ADR-0018 PR. The first three phases land **additively** — the
117+
node path is built and proven green *beside* the standalone engine, so the destructive
118+
removal (A4/A5) can be reviewed and sequenced on its own once consumers move over.
119+
120+
1. **A1 — this ADR.****Done.** Fix the boundary before code.
121+
2. **A2 — engine durable pause + node config schema.****Done.** Generalized the engine's
122+
durable-pause into a real **suspend/resume** primitive (`AutomationResult.status: 'paused'`
123+
+ `runId`, `IAutomationService.resume` / `listSuspendedRuns`, in-memory `suspendedRuns`;
124+
the `screen` node opts in via `config.waitForInput`). Added the canonical Approval **node**
125+
config (`ApprovalNodeConfigSchema`, `APPROVAL_NODE_TYPE`, `ApprovalDecision`,
126+
`APPROVAL_BRANCH_LABELS`) lowering `ApproverType` / `behavior` / `escalation` /
127+
`lockRecord` / `approvalStatusField` to node config; deprecated `ApprovalProcessSchema`
128+
(JSDoc) without removing it yet.
129+
3. **A3 — node provider (additive).****Done.** `plugin-approvals` now contributes the
130+
`approval` node via the ADR-0018 registry (`approval-node.ts`): on entry it opens a
131+
`sys_approval_request` (reusing approver resolution / audit / lock / status mirror verbatim)
132+
and **suspends**; `decideApprovalNode` finalizes and **resumes** the run down the matching
133+
`approve` / `reject` edge. New correlation fields on `sys_approval_request`
134+
(`flow_run_id` / `flow_node_id` / `node_config_json`). The standalone process engine is left
135+
intact for the migration window.
136+
4. **A4 — delete parallel pieces.****Follow-up PR (destructive).** Remove
137+
`action-executor.ts`, `ApprovalActionType`, `ApprovalProcessSchema` (top-level) +
138+
`approval.form.ts`; route all actions through the ADR-0018 registry; retire `process_hash`
139+
pinning in favor of Flow pinning. Gated on consumers (CRM examples, API routes, app seeders,
140+
`metadata-type-schemas.ts` / `metadata-form-registry.ts`) migrating off the process model.
141+
5. **A5 — cleanup.****Follow-up PR.** Remove the `workflow_rule` paradigm remnants (the M5
142+
compiler itself was already removed in #1398) and `connector_action` remnants in
143+
`flow.zod.ts`; migrate `approval-service.test.ts` / `phase-b.test.ts` to drive the
144+
Approval node.
145+
146+
> **Landed in this PR:** A1–A3. The engine gained real durable suspend/resume (P1), spec gained
147+
> the Approval node contract (P2), and `plugin-approvals` gained the working node bridge (P3) —
148+
> all additive and green (spec 6605, service-automation 79, plugin-approvals 41). A4/A5 are the
149+
> destructive removal of the now-superseded standalone engine and are deliberately a separate PR.
150+
151+
## Migration map
152+
153+
| Asset | Disposition |
154+
|:---|:---|
155+
| `plugin-approvals` execution loop + `action-executor.ts` | **Delete** (engine + actions now Flow's) |
156+
| `ApprovalActionType`, `connector_action` remnants, M5 compiler | **Delete** |
157+
| `ApprovalProcessSchema`, `approval.form.ts` (top-level authoring type) | **Deprecate / remove** — concepts → Approval node config + Flow graph |
158+
| `ApproverType`, `behavior`, `escalation`, `lockRecord`, `approvalStatusField` | **Re-home** → Approval node config schema |
159+
| Approver resolution (team/dept BFS/manager/role/queue) | **Keep** (move under node, ~verbatim) |
160+
| `sys_approval_request` / `sys_approval_action`, lock hook, status mirror | **Keep** (first-class approval state, owned by the plugin) |
161+
| `approval-service.test.ts` / `phase-b.test.ts` | **Migrate** to drive the Approval node |
162+
163+
## Tiering (open-source vs enterprise)
164+
165+
The open-source / enterprise split is **not** an architectural concern and is **out of scope for this ADR** — the open registry (ADR-0018) plus the node-config shape make the tier line a *packaging* decision (which approver types / orchestration features ship in which package), not an engine boundary. The split is maintained privately in `cloud/docs/design/approval-tiering.md`. This ADR keeps the engine and the node contract tier-neutral.
166+

packages/platform-objects/src/audit/sys-approval-request.object.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,36 @@ export const SysApprovalRequest = ObjectSchema.create({
175175
group: 'State',
176176
}),
177177

178+
// ── ADR-0019: approval-as-flow-node correlation ──────────────────
179+
// When a request is opened by an Approval *node* (rather than a standalone
180+
// process), these tie it back to the suspended flow run so a decision can
181+
// resume it. Null for legacy process-driven requests.
182+
flow_run_id: Field.text({
183+
label: 'Flow Run',
184+
required: false,
185+
maxLength: 100,
186+
readonly: true,
187+
description: 'Suspended automation run id this request gates (ADR-0019). The decision resumes it.',
188+
group: 'State',
189+
}),
190+
191+
flow_node_id: Field.text({
192+
label: 'Flow Node',
193+
required: false,
194+
maxLength: 100,
195+
readonly: true,
196+
description: 'Approval node id within the flow that opened this request (ADR-0019).',
197+
group: 'State',
198+
}),
199+
200+
node_config_json: Field.textarea({
201+
label: 'Node Config',
202+
required: false,
203+
readonly: true,
204+
description: 'Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019).',
205+
group: 'State',
206+
}),
207+
178208
completed_at: Field.datetime({
179209
label: 'Completed At',
180210
required: false,

packages/plugins/plugin-approvals/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@objectstack/spec": "workspace:*"
2525
},
2626
"devDependencies": {
27+
"@objectstack/service-automation": "workspace:*",
2728
"@types/node": "^25.9.1",
2829
"typescript": "^6.0.3",
2930
"vitest": "^4.1.7"

0 commit comments

Comments
 (0)