|
| 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 | + |
0 commit comments