Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 22 additions & 16 deletions docs/adr/0019-approval-as-flow-node.md
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# ADR-0019: Collapse Approval into Flow — one engine, approval as a durable-pause node

**Status**: Proposed (2026-05-31)
**Status**: Accepted (2026-05-31) — fully implemented (A1–A5)
**Deciders**: ObjectStack Protocol Architects
**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)
**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).
Expand DownExpand Up@@ -133,27 +133,33 @@ removal (A4/A5) can be reviewed and sequenced on its own once consumers move ove
`approve` / `reject` edge. New correlation fields on `sys_approval_request`
(`flow_run_id` / `flow_node_id` / `node_config_json`). The standalone process engine is left
intact for the migration window.
4. **A4 — delete parallel pieces.** ⏳ **Follow-up PR (destructive).** Remove
`action-executor.ts`, `ApprovalActionType`, `ApprovalProcessSchema` (top-level) +
`approval.form.ts`; route all actions through the ADR-0018 registry; retire `process_hash`
pinning in favor of Flow pinning. Gated on consumers (CRM examples, API routes, app seeders,
`metadata-type-schemas.ts` / `metadata-form-registry.ts`) migrating off the process model.
5. **A5 — cleanup.** ⏳ **Follow-up PR.** Remove the `workflow_rule` paradigm remnants (the M5
compiler itself was already removed in #1398) and `connector_action` remnants in
`flow.zod.ts`; migrate `approval-service.test.ts` / `phase-b.test.ts` to drive the
Approval node.

> **Landed in this PR:** A1–A3. The engine gained real durable suspend/resume (P1), spec gained
> the Approval node contract (P2), and `plugin-approvals` gained the working node bridge (P3) —
> all additive and green (spec 6605, service-automation 79, plugin-approvals 41). A4/A5 are the
> destructive removal of the now-superseded standalone engine and are deliberately a separate PR.
4. **A4 — delete parallel pieces.** ✅ **Done (this PR, destructive).** Removed
`action-executor.ts`, `ApprovalActionType`, `ApprovalProcessSchema` / `ApprovalStepSchema` /
`ApprovalActionSchema` (top-level) + `approval.form.ts`, the `sys_approval_process` object,
the `approvals` stack collection, the lifecycle auto-trigger, the REST `/approvals/processes`
+ submit/recall routes, and the app-plugin process seeder; retired `process_hash` pinning in
favor of Flow pinning. All actions now route through the ADR-0018 registry. Consumers (CRM /
showcase examples, API routes, app seeders, `metadata-type-schemas.ts` /
`metadata-form-registry.ts`, CLI / metadata stats) migrated off the process model.
5. **A5 — cleanup.** ✅ **Done (this PR).** The M5 compiler was already removed in #1398; the
`workflow_rule` paradigm remnants are gone with the process engine. `connector_action` is
**retained** — it is a deliberate open extension point on the ADR-0018 registry, not a process
remnant. `approval-service.test.ts` rewritten to drive the Approval node; `phase-b.test.ts`
deleted.

> **Landed across two PRs:** A1–A3 (additive foundation) shipped first — the engine gained real
> durable suspend/resume (P1), spec gained the Approval node contract (P2), and `plugin-approvals`
> gained the working node bridge (P3). **This PR lands A4–A5**: the destructive removal of the
> now-superseded standalone process engine. Approval exists *only* as a flow node. Green across
> spec / platform-objects / plugin-approvals / runtime / rest / cli / metadata and both example apps.

## Migration map

| Asset | Disposition |
|:---|:---|
| `plugin-approvals` execution loop + `action-executor.ts` | **Delete** (engine + actions now Flow's) |
| `ApprovalActionType`, `connector_action` remnants, M5 compiler | **Delete** |
| `ApprovalActionType`, M5 compiler (`workflow_rule`) | **Delete** |
| `connector_action` | **Keep** — deliberate open extension point (ADR-0018), not a process remnant |
| `ApprovalProcessSchema`, `approval.form.ts` (top-level authoring type) | **Deprecate / remove** — concepts → Approval node config + Flow graph |
| `ApproverType`, `behavior`, `escalation`, `lockRecord`, `approvalStatusField` | **Re-home** → Approval node config schema |
| Approver resolution (team/dept BFS/manager/role/queue) | **Keep** (move under node, ~verbatim) |
Expand Down
2 changes: 0 additions & 2 deletions examples/app-crm/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,6 @@ import * as emails from './src/emails/index.js';
import { allHooks } from './src/hooks/index.js';
import { allFlows } from './src/flows/index.js';
import { HighValueDealWorkflow, StaleOpportunityWorkflow } from './src/workflows/index.js';
import { DiscountApprovalProcess } from './src/approvals/index.js';
import {
SalesAssistantAgent,
LookupContactTool,
Expand DownExpand Up@@ -105,7 +104,6 @@ export default defineStack({
hooks: allHooks,
flows: allFlows,
workflows: [HighValueDealWorkflow, StaleOpportunityWorkflow],
approvals: [DiscountApprovalProcess],
jobs: [LeadScoringJob, PipelineReportJob, RenewalSweepJob],
emailTemplates: Object.values(emails),

Expand Down
36 changes: 0 additions & 36 deletions examples/app-crm/src/approvals/discount-approval.approval.ts

This file was deleted.

3 changes: 0 additions & 3 deletions examples/app-crm/src/approvals/index.ts

This file was deleted.

75 changes: 75 additions & 0 deletions examples/app-crm/src/flows/discount-approval.flow.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

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

/**
* Discount approval — ADR-0019 approval-as-flow-node.
*
* What used to be a standalone two-step approval *process* is now an ordinary
* autolaunched flow with two `approval` nodes. The flow suspends on each
* approval and resumes down the matching `approve` / `reject` edge:
*
* start → manager_review ──approve──▶ finance_review ──approve──▶ end
* └─reject──▶ rejected └─reject──▶ rejected
*
* Finance only signs off when the discount exceeds 30% — that gate is just a
* decision node on the approve edge out of the manager step.
*/
export const DiscountApprovalFlow: Flow = {
name: 'crm_discount_approval',
label: 'Opportunity Discount Approval',
description: 'Two-step approval for opportunities with significant discounts.',
type: 'autolaunched',

nodes: [
{
id: 'start',
type: 'start',
label: 'On Discount Above Threshold',
config: {
objectName: 'crm_opportunity',
triggerType: 'record-after-update',
condition: 'discount_percent > 20',
},
},
{
id: 'manager_review',
type: 'approval',
label: 'Manager Review',
config: {
approvers: [{ type: 'role', value: 'sales_manager' }],
behavior: 'first_response',
lockRecord: true,
approvalStatusField: 'approval_status',
},
},
{
id: 'needs_finance',
type: 'decision',
label: 'Discount Above 30%?',
config: { condition: 'discount_percent > 30' },
},
{
id: 'finance_review',
type: 'approval',
label: 'Finance Review',
config: {
approvers: [{ type: 'role', value: 'finance_approver' }],
behavior: 'unanimous',
lockRecord: true,
approvalStatusField: 'approval_status',
},
},
{ id: 'approved', type: 'end', label: 'Approved' },
{ id: 'rejected', type: 'end', label: 'Rejected' },
],
edges: [
{ id: 'e1', source: 'start', target: 'manager_review' },
{ id: 'e2', source: 'manager_review', target: 'needs_finance', label: 'approve' },
{ id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' },
{ id: 'e4', source: 'needs_finance', target: 'finance_review', label: 'true' },
{ id: 'e5', source: 'needs_finance', target: 'approved', label: 'false' },
{ id: 'e6', source: 'finance_review', target: 'approved', label: 'approve' },
{ id: 'e7', source: 'finance_review', target: 'rejected', label: 'reject' },
],
};
3 changes: 2 additions & 1 deletion examples/app-crm/src/flows/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,8 @@ import { OpportunityWonFlow } from './opportunity-won.flow.js';
import { LeadQualificationFlow } from './lead-qualification.flow.js';
import { RenewalReminderFlow } from './renewal-reminder.flow.js';
import { ConvertLeadScreenFlow } from './convert-lead.flow.js';
import { DiscountApprovalFlow } from './discount-approval.flow.js';

export { ConvertLeadScreenFlow } from './convert-lead.flow.js';

export const allFlows = [OpportunityWonFlow, LeadQualificationFlow, RenewalReminderFlow, ConvertLeadScreenFlow];
export const allFlows = [OpportunityWonFlow, LeadQualificationFlow, RenewalReminderFlow, ConvertLeadScreenFlow, DiscountApprovalFlow];
13 changes: 13 additions & 0 deletions examples/app-crm/src/objects/opportunity.object.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,6 +56,19 @@ export const Opportunity = ObjectSchema.create({
min: 0,
max: 100,
}),
// Mirror target for the Discount Approval flow's approval nodes
// (ADR-0019). The approval runtime writes the request status here; it is
// readonly to users so only the flow drives it.
approval_status: Field.select({
label: 'Approval Status',
readonly: true,
options: [
{ label: 'Pending', value: 'pending', color: '#F59E0B' },
{ label: 'Approved', value: 'approved', color: '#10B981' },
{ label: 'Rejected', value: 'rejected', color: '#EF4444' },
{ label: 'Recalled', value: 'recalled', color: '#94A3B8' },
],
}),
renewal_of: Field.lookup('crm_opportunity', {
label: 'Renewal Of',
}),
Expand Down
2 changes: 0 additions & 2 deletions examples/app-showcase/objectstack.config.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,7 +10,6 @@ import { allReports } from './src/reports/index.js';
import { allActions } from './src/actions/index.js';
import { ComponentGalleryPage } from './src/pages/index.js';
import { allFlows } from './src/flows/index.js';
import { allApprovals } from './src/approvals/index.js';
import { allWebhooks } from './src/webhooks/index.js';
import { allJobs } from './src/jobs/index.js';
import { allEmails } from './src/emails/index.js';
Expand DownExpand Up@@ -85,7 +84,6 @@ export default defineStack({

// Logic
flows: allFlows,
approvals: allApprovals,
jobs: allJobs,
emailTemplates: allEmails,
webhooks: allWebhooks,
Expand Down
37 changes: 0 additions & 37 deletions examples/app-showcase/src/approvals/index.ts

This file was deleted.

2 changes: 1 addition & 1 deletion examples/app-showcase/src/coverage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -67,7 +67,7 @@ export const COVERAGE = {
},
capabilityChains: {
security: 'security/index.ts — roles + permission set (CRUD + FLS + RLS) + sharing + policy',
automation: 'flows/index.ts + approvals/index.ts + webhooks/index.ts + jobs/index.ts + emails/index.ts',
automation: 'flows/index.ts (incl. approval nodes) + webhooks/index.ts + jobs/index.ts + emails/index.ts',
ai: 'agents/index.ts — agent + tool + skill',
},
i18nThemingPortals: {
Expand Down
67 changes: 66 additions & 1 deletion examples/app-showcase/src/flows/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -89,4 +89,69 @@ export const ReassignWizardFlow = defineFlow({
],
});

export const allFlows = [TaskCompletedFlow, ReassignWizardFlow];
/**
* Project Budget Approval — ADR-0019 approval-as-flow-node.
*
* What used to be a standalone two-step approval *process* is now an ordinary
* autolaunched flow with two `approval` nodes. The flow suspends on each
* approval and resumes down the matching `approve` / `reject` edge. The
* executive step only runs for budgets above $500k — that gate is a decision
* node on the manager step's approve edge.
*/
export const BudgetApprovalFlow = defineFlow({
name: 'showcase_budget_approval',
label: 'Project Budget Approval',
description: 'Two-step approval for projects above budget thresholds.',
type: 'autolaunched',
nodes: [
{
id: 'start',
type: 'start',
label: 'On Large Budget',
config: {
objectName: 'showcase_project',
triggerType: 'record-after-update',
condition: 'budget > 100000',
},
},
{
id: 'manager_review',
type: 'approval',
label: 'Manager Review',
config: {
approvers: [{ type: 'role', value: 'manager' }],
behavior: 'first_response',
lockRecord: true,
},
},
{
id: 'needs_exec',
type: 'decision',
label: 'Budget Above $500k?',
config: { condition: 'budget > 500000' },
},
{
id: 'exec_review',
type: 'approval',
label: 'Executive Review',
config: {
approvers: [{ type: 'role', value: 'exec' }],
behavior: 'unanimous',
lockRecord: true,
},
},
{ id: 'approved', type: 'end', label: 'Approved' },
{ id: 'rejected', type: 'end', label: 'Rejected' },
],
edges: [
{ id: 'e1', source: 'start', target: 'manager_review' },
{ id: 'e2', source: 'manager_review', target: 'needs_exec', label: 'approve' },
{ id: 'e3', source: 'manager_review', target: 'rejected', label: 'reject' },
{ id: 'e4', source: 'needs_exec', target: 'exec_review', label: 'true' },
{ id: 'e5', source: 'needs_exec', target: 'approved', label: 'false' },
{ id: 'e6', source: 'exec_review', target: 'approved', label: 'approve' },
{ id: 'e7', source: 'exec_review', target: 'rejected', label: 'reject' },
],
});

export const allFlows = [TaskCompletedFlow, ReassignWizardFlow, BudgetApprovalFlow];
1 change: 0 additions & 1 deletion examples/app-showcase/test/seed.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,7 +22,6 @@ describe('showcase stack', () => {
expect((stack.dashboards ?? []).length).toBeGreaterThan(0);
expect((stack.reports ?? []).length).toBe(4);
expect((stack.flows ?? []).length).toBeGreaterThan(0);
expect((stack.approvals ?? []).length).toBeGreaterThan(0);
expect((stack.roles ?? []).length).toBe(3);
expect((stack.agents ?? []).length).toBe(1);
});
Expand Down
3 changes: 0 additions & 3 deletions packages/cli/src/utils/format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,7 +121,6 @@ export interface MetadataStats {
actions: number;
flows: number;
workflows: number;
approvals: number;
agents: number;
apis: number;
roles: number;
Expand DownExpand Up@@ -162,7 +161,6 @@ export function collectMetadataStats(config: any): MetadataStats {
actions: count(config.actions),
flows: count(config.flows),
workflows: count(config.workflows),
approvals: count(config.approvals),
agents: count(config.agents),
apis: count(config.apis),
roles: count(config.roles),
Expand DownExpand Up@@ -249,7 +247,6 @@ export function printMetadataStats(stats: MetadataStats) {
items: [
['Flows', stats.flows],
['Workflows', stats.workflows],
['Approvals', stats.approvals],
['Agents', stats.agents],
['APIs', stats.apis],
],
Expand Down
1 change: 0 additions & 1 deletion packages/metadata/src/plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,6 @@ const ARTIFACT_FIELD_TO_TYPE: Record<string, string> = {
actions: 'action',
themes: 'theme',
workflows: 'workflow',
approvals: 'approval',
flows: 'flow',
roles: 'role',
permissions: 'permission',
Expand Down
Loading