diff --git a/docs/adr/0019-approval-as-flow-node.md b/docs/adr/0019-approval-as-flow-node.md index a197313673..00628e10ed 100644 --- a/docs/adr/0019-approval-as-flow-node.md +++ b/docs/adr/0019-approval-as-flow-node.md @@ -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). @@ -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) | diff --git a/examples/app-crm/objectstack.config.ts b/examples/app-crm/objectstack.config.ts index 3804bdceb9..d1727d05a3 100644 --- a/examples/app-crm/objectstack.config.ts +++ b/examples/app-crm/objectstack.config.ts @@ -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, @@ -105,7 +104,6 @@ export default defineStack({ hooks: allHooks, flows: allFlows, workflows: [HighValueDealWorkflow, StaleOpportunityWorkflow], - approvals: [DiscountApprovalProcess], jobs: [LeadScoringJob, PipelineReportJob, RenewalSweepJob], emailTemplates: Object.values(emails), diff --git a/examples/app-crm/src/approvals/discount-approval.approval.ts b/examples/app-crm/src/approvals/discount-approval.approval.ts deleted file mode 100644 index cef22dbc69..0000000000 --- a/examples/app-crm/src/approvals/discount-approval.approval.ts +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Automation } from '@objectstack/spec'; - -/** - * Example approval process — discount approval on opportunities. - * Routes to sales manager, then finance. - */ -export const DiscountApprovalProcess: Automation.ApprovalProcess = { - name: 'crm_discount_approval', - label: 'Opportunity Discount Approval', - object: 'crm_opportunity', - active: true, - description: 'Two-step approval for opportunities with significant discounts.', - entryCriteria: 'record.discount_percent > 20', - lockRecord: true, - steps: [ - { - name: 'manager_review', - label: 'Manager Review', - description: 'First-line sales manager reviews the discount.', - approvers: [{ type: 'role', value: 'sales_manager' }], - behavior: 'first_response', - rejectionBehavior: 'reject_process', - }, - { - name: 'finance_review', - label: 'Finance Review', - description: 'Finance signs off if discount exceeds 30%.', - entryCriteria: 'record.discount_percent > 30', - approvers: [{ type: 'role', value: 'finance_approver' }], - behavior: 'unanimous', - rejectionBehavior: 'back_to_previous', - }, - ], -}; diff --git a/examples/app-crm/src/approvals/index.ts b/examples/app-crm/src/approvals/index.ts deleted file mode 100644 index 386b030da0..0000000000 --- a/examples/app-crm/src/approvals/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -export { DiscountApprovalProcess } from './discount-approval.approval.js'; diff --git a/examples/app-crm/src/flows/discount-approval.flow.ts b/examples/app-crm/src/flows/discount-approval.flow.ts new file mode 100644 index 0000000000..41fd145012 --- /dev/null +++ b/examples/app-crm/src/flows/discount-approval.flow.ts @@ -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' }, + ], +}; diff --git a/examples/app-crm/src/flows/index.ts b/examples/app-crm/src/flows/index.ts index a69dd34cc2..caadc90051 100644 --- a/examples/app-crm/src/flows/index.ts +++ b/examples/app-crm/src/flows/index.ts @@ -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]; diff --git a/examples/app-crm/src/objects/opportunity.object.ts b/examples/app-crm/src/objects/opportunity.object.ts index 3272f42bba..cc059740eb 100644 --- a/examples/app-crm/src/objects/opportunity.object.ts +++ b/examples/app-crm/src/objects/opportunity.object.ts @@ -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', }), diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index afbc3be4c7..bca3110dde 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -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'; @@ -85,7 +84,6 @@ export default defineStack({ // Logic flows: allFlows, - approvals: allApprovals, jobs: allJobs, emailTemplates: allEmails, webhooks: allWebhooks, diff --git a/examples/app-showcase/src/approvals/index.ts b/examples/app-showcase/src/approvals/index.ts deleted file mode 100644 index 1d3c12c500..0000000000 --- a/examples/app-showcase/src/approvals/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Budget Approval — a two-step approval that locks a project when its budget - * exceeds a threshold: manager first, then executive for very large budgets. - * Validated (and its string criteria coerced to expressions) by `defineStack`. - */ -export const BudgetApprovalProcess = { - name: 'showcase_budget_approval', - label: 'Project Budget Approval', - object: 'showcase_project', - active: true, - description: 'Two-step approval for projects above budget thresholds.', - entryCriteria: 'record.budget > 100000', - lockRecord: true, - steps: [ - { - name: 'manager_review', - label: 'Manager Review', - description: 'Project manager reviews the budget.', - approvers: [{ type: 'role' as const, value: 'manager' }], - behavior: 'first_response' as const, - rejectionBehavior: 'reject_process' as const, - }, - { - name: 'exec_review', - label: 'Executive Review', - description: 'Executive signs off on budgets above $500k.', - entryCriteria: 'record.budget > 500000', - approvers: [{ type: 'role' as const, value: 'exec' }], - behavior: 'unanimous' as const, - rejectionBehavior: 'back_to_previous' as const, - }, - ], -}; - -export const allApprovals = [BudgetApprovalProcess]; diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 1b22aca9e4..5f61c3cdd6 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -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: { diff --git a/examples/app-showcase/src/flows/index.ts b/examples/app-showcase/src/flows/index.ts index c35cb29109..552e609c7e 100644 --- a/examples/app-showcase/src/flows/index.ts +++ b/examples/app-showcase/src/flows/index.ts @@ -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]; diff --git a/examples/app-showcase/test/seed.test.ts b/examples/app-showcase/test/seed.test.ts index 24e6f0581e..7098eb4db8 100644 --- a/examples/app-showcase/test/seed.test.ts +++ b/examples/app-showcase/test/seed.test.ts @@ -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); }); diff --git a/packages/cli/src/utils/format.ts b/packages/cli/src/utils/format.ts index efeebec5f7..d4852dbea7 100644 --- a/packages/cli/src/utils/format.ts +++ b/packages/cli/src/utils/format.ts @@ -121,7 +121,6 @@ export interface MetadataStats { actions: number; flows: number; workflows: number; - approvals: number; agents: number; apis: number; roles: number; @@ -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), @@ -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], ], diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 3c9cf8c9e1..7ade927ed3 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -55,7 +55,6 @@ const ARTIFACT_FIELD_TO_TYPE: Record = { actions: 'action', themes: 'theme', workflows: 'workflow', - approvals: 'approval', flows: 'flow', roles: 'role', permissions: 'permission', diff --git a/packages/objectql/src/metadata-validation-sweep.test.ts b/packages/objectql/src/metadata-validation-sweep.test.ts index 91e75cd172..4177adbe11 100644 --- a/packages/objectql/src/metadata-validation-sweep.test.ts +++ b/packages/objectql/src/metadata-validation-sweep.test.ts @@ -150,15 +150,16 @@ const FIXTURES: Record = { invalidatedField: 'type', }, workflow: { + // `workflow` is the state-machine schema (StateMachineSchema): id + + // initial + states. (The legacy workflow-rule shape was retired.) valid: { name: 'sweep_wf', - label: 'Sweep', - objectName: 'sweep_account', - triggerType: 'on_create', - criteria: 'record.amount > 0', + id: 'sweep_wf', + initial: 'open', + states: { open: { type: 'final' } }, }, - invalid: { name: 'sweep_wf', label: 'Sweep' }, - invalidatedField: 'objectName', + invalid: { name: 'sweep_wf', id: 'sweep_wf', initial: 'open' }, + invalidatedField: 'states', }, approval: { valid: { diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index 7a4de1a96e..6ee444f3bc 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -73,7 +73,6 @@ import { SysEmailTemplate, SysSavedReport, SysReportSchedule, - SysApprovalProcess, SysApprovalRequest, SysApprovalAction, SysJob, @@ -166,7 +165,6 @@ export default defineStack({ SysEmailTemplate, SysSavedReport, SysReportSchedule, - SysApprovalProcess, SysApprovalRequest, SysApprovalAction, SysJob, diff --git a/packages/platform-objects/src/apps/setup.app.ts b/packages/platform-objects/src/apps/setup.app.ts index ba28b9ce0d..a16db6b75b 100644 --- a/packages/platform-objects/src/apps/setup.app.ts +++ b/packages/platform-objects/src/apps/setup.app.ts @@ -138,7 +138,6 @@ export const SETUP_APP: App = { // reused across tenants. Hidden from org admins. requiredPermissions: ['manage_platform_settings'], children: [ - { id: 'nav_approval_processes', type: 'object', label: 'Processes', objectName: 'sys_approval_process', icon: 'workflow', requiresObject: 'sys_approval_process' }, { id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' }, { id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' }, ], diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 57d4745b5f..1132248d03 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -2331,58 +2331,6 @@ export const enObjects: NonNullable = { } } }, - sys_approval_process: { - label: "Approval Process", - pluralLabel: "Approval Processes", - description: "Persisted approval process definition. Authored via defineApprovalProcess() in code; visual designer is on the roadmap.", - fields: { - id: { - label: "Process ID" - }, - name: { - label: "Name", - help: "Unique snake_case name — referenced by submitters and audit rows" - }, - label: { - label: "Display Label" - }, - object_name: { - label: "Object", - help: "Short object name this process governs" - }, - description: { - label: "Description" - }, - active: { - label: "Active", - help: "Only active processes are dispatched on submission" - }, - definition_json: { - label: "Definition", - help: "Serialised ApprovalProcess JSON (see @objectstack/spec/automation/approval)" - }, - created_at: { - label: "Created At" - }, - updated_at: { - label: "Updated At" - } - }, - _views: { - active: { - label: "Active" - }, - inactive: { - label: "Inactive" - }, - by_object: { - label: "By Object" - }, - all_processes: { - label: "All" - } - } - }, sys_approval_request: { label: "Approval Request", pluralLabel: "Approval Requests", @@ -2396,8 +2344,8 @@ export const enObjects: NonNullable = { help: "Tenant that owns this approval request (propagated from submitter context)" }, process_name: { - label: "Process", - help: "sys_approval_process.name this request was opened against" + label: "Source", + help: "Origin of the request — `flow:` for node-driven approvals" }, object_name: { label: "Object" diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 91ac34f6d0..616cf7aa21 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -2331,58 +2331,6 @@ export const esESObjects: NonNullable = { } } }, - sys_approval_process: { - label: "Proceso de aprobación", - pluralLabel: "Procesos de aprobación", - description: "Definición persistida del proceso de aprobación. Se crea mediante defineApprovalProcess() en código; el diseñador visual está en la hoja de ruta.", - fields: { - id: { - label: "ID de proceso" - }, - name: { - label: "Nombre", - help: "Nombre snake_case único; lo utilizan los remitentes y las filas de auditoría." - }, - label: { - label: "Nombre visible" - }, - object_name: { - label: "Objeto", - help: "Nombre corto del objeto que rige este proceso." - }, - description: { - label: "Descripción" - }, - active: { - label: "Activo", - help: "Solo los procesos activos se despachan al enviarse." - }, - definition_json: { - label: "Definición", - help: "JSON serializado de ApprovalProcess (consulte @objectstack/spec/automation/approval)." - }, - created_at: { - label: "Creado el" - }, - updated_at: { - label: "Actualizado el" - } - }, - _views: { - active: { - label: "Activo" - }, - inactive: { - label: "Inactivo" - }, - by_object: { - label: "Por objeto" - }, - all_processes: { - label: "Todos" - } - } - }, sys_approval_request: { label: "Solicitud de aprobación", pluralLabel: "Solicitudes de aprobación", @@ -2396,8 +2344,8 @@ export const esESObjects: NonNullable = { help: "Tenant que posee esta solicitud de aprobación (propagado desde el contexto del solicitante)." }, process_name: { - label: "Proceso", - help: "sys_approval_process.name contra el que se abrió esta solicitud." + label: "Origen", + help: "Origen de la solicitud — `flow:` para aprobaciones por nodo" }, object_name: { label: "Objeto" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 3384746037..2ad89f7894 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -2331,58 +2331,6 @@ export const jaJPObjects: NonNullable = { } } }, - sys_approval_process: { - label: "承認プロセス", - pluralLabel: "承認プロセス", - description: "永続化された承認プロセス定義。コードの defineApprovalProcess() で作成。ビジュアルデザイナーはロードマップに予定。", - fields: { - id: { - label: "プロセス ID" - }, - name: { - label: "名前", - help: "一意の snake_case 名 — 送信者と監査行から参照" - }, - label: { - label: "表示名" - }, - object_name: { - label: "オブジェクト", - help: "このプロセスが管理する短いオブジェクト名" - }, - description: { - label: "説明" - }, - active: { - label: "有効", - help: "有効なプロセスのみが送信時にディスパッチされます" - }, - definition_json: { - label: "定義", - help: "シリアライズされた ApprovalProcess JSON(@objectstack/spec/automation/approval 参照)" - }, - created_at: { - label: "作成日時" - }, - updated_at: { - label: "更新日時" - } - }, - _views: { - active: { - label: "有効" - }, - inactive: { - label: "無効" - }, - by_object: { - label: "オブジェクト別" - }, - all_processes: { - label: "すべて" - } - } - }, sys_approval_request: { label: "承認リクエスト", pluralLabel: "承認リクエスト", @@ -2396,8 +2344,8 @@ export const jaJPObjects: NonNullable = { help: "この承認リクエストを所有するテナント(送信者コンテキストから伝播)" }, process_name: { - label: "プロセス", - help: "このリクエストが開かれた sys_approval_process.name" + label: "ソース", + help: "リクエストの発生元 — ノード駆動の承認では `flow:`" }, object_name: { label: "オブジェクト" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index e937c04a82..8fdbffa573 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -2331,58 +2331,6 @@ export const zhCNObjects: NonNullable = { } } }, - sys_approval_process: { - label: "审批流程", - pluralLabel: "审批流程", - description: "持久化的审批流程定义。通过代码中的 defineApprovalProcess() 编写;可视化设计器在规划中。", - fields: { - id: { - label: "流程 ID" - }, - name: { - label: "名称", - help: "唯一的 snake_case 名称——由提交方和审计记录引用" - }, - label: { - label: "显示标签" - }, - object_name: { - label: "对象", - help: "该流程所管理的短对象名" - }, - description: { - label: "描述" - }, - active: { - label: "启用", - help: "仅启用的流程会在提交时分派" - }, - definition_json: { - label: "定义", - help: "序列化的 ApprovalProcess JSON(参见 @objectstack/spec/automation/approval)" - }, - created_at: { - label: "创建时间" - }, - updated_at: { - label: "更新时间" - } - }, - _views: { - active: { - label: "启用" - }, - inactive: { - label: "停用" - }, - by_object: { - label: "按对象" - }, - all_processes: { - label: "全部" - } - } - }, sys_approval_request: { label: "审批请求", pluralLabel: "审批请求", @@ -2396,8 +2344,8 @@ export const zhCNObjects: NonNullable = { help: "拥有该审批请求的租户(从提交方上下文传播)" }, process_name: { - label: "流程", - help: "该请求对应的 sys_approval_process.name" + label: "来源", + help: "请求来源 —— 节点驱动的审批为 `flow:`" }, object_name: { label: "对象" diff --git a/packages/platform-objects/src/audit/index.ts b/packages/platform-objects/src/audit/index.ts index 81cf5fbaa9..032116accc 100644 --- a/packages/platform-objects/src/audit/index.ts +++ b/packages/platform-objects/src/audit/index.ts @@ -14,7 +14,6 @@ export { SysEmail } from './sys-email.object.js'; export { SysEmailTemplate } from './sys-email-template.object.js'; export { SysSavedReport } from './sys-saved-report.object.js'; export { SysReportSchedule } from './sys-report-schedule.object.js'; -export { SysApprovalProcess } from './sys-approval-process.object.js'; export { SysApprovalRequest } from './sys-approval-request.object.js'; export { SysApprovalAction } from './sys-approval-action.object.js'; export { SysJob } from './sys-job.object.js'; diff --git a/packages/platform-objects/src/audit/sys-approval-process.object.ts b/packages/platform-objects/src/audit/sys-approval-process.object.ts deleted file mode 100644 index e40cf67569..0000000000 --- a/packages/platform-objects/src/audit/sys-approval-process.object.ts +++ /dev/null @@ -1,144 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; - -/** - * sys_approval_process — Approval Process Definition (runtime row). - * - * Persists an {@link ApprovalProcess} configuration so administrators - * can author and version approval flows from the UI without code - * changes. The Zod schema for the JSON envelope lives at - * `@objectstack/spec/automation/approval` — this row simply stores - * a serialised copy alongside the lookup metadata the engine needs to - * route incoming submissions. - * - * One row per `name`. The dispatcher (the `approvals` service) finds - * the active process for an `(object_name, recordId)` pair by querying - * `active=true` rows where `object_name` matches. - * - * @namespace sys - */ -export const SysApprovalProcess = ObjectSchema.create({ - name: 'sys_approval_process', - label: 'Approval Process', - pluralLabel: 'Approval Processes', - icon: 'check-square', - isSystem: true, - managedBy: 'config', - // Authoring an approval process requires a visual step designer that - // doesn't yet exist — the embedded `definition_json` textarea would - // force admins to hand-write a multi-page ApprovalProcess envelope. - // Suppress generic CRUD until the designer lands. Real authoring path: - // call `defineApprovalProcess({...})` in code and seed via the - // approvals service (`POST /api/v1/approvals/processes`) or commit the - // definition as a fixture. Editing existing rows (e.g. toggling - // `active`) is also suppressed for now because the same textarea would - // appear; use the service API or a future designer instead. - userActions: { create: false, edit: false, delete: false, import: false }, - description: 'Persisted approval process definition. Authored via defineApprovalProcess() in code; visual designer is on the roadmap.', - displayNameField: 'name', - titleFormat: '{label}', - compactLayout: ['name', 'object_name', 'active', 'updated_at'], - - listViews: { - active: { - type: 'grid', - name: 'active', - label: 'Active', - data: { provider: 'object', object: 'sys_approval_process' }, - columns: ['label', 'object_name', 'active', 'updated_at'], - filter: [{ field: 'active', operator: 'equals', value: true }], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - inactive: { - type: 'grid', - name: 'inactive', - label: 'Inactive', - data: { provider: 'object', object: 'sys_approval_process' }, - columns: ['label', 'object_name', 'active', 'updated_at'], - filter: [{ field: 'active', operator: 'equals', value: false }], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - by_object: { - type: 'grid', - name: 'by_object', - label: 'By Object', - data: { provider: 'object', object: 'sys_approval_process' }, - columns: ['object_name', 'label', 'active', 'updated_at'], - sort: [{ field: 'object_name', order: 'asc' }, { field: 'label', order: 'asc' }], - grouping: { fields: [{ field: 'object_name', order: 'asc', collapsed: false }] }, - pagination: { pageSize: 100 }, - }, - all_processes: { - type: 'grid', - name: 'all_processes', - label: 'All', - data: { provider: 'object', object: 'sys_approval_process' }, - columns: ['label', 'object_name', 'active', 'updated_at'], - sort: [{ field: 'label', order: 'asc' }], - pagination: { pageSize: 50 }, - }, - }, - - fields: { - id: Field.text({ label: 'Process ID', required: true, readonly: true, group: 'System' }), - - name: Field.text({ - label: 'Name', - required: true, - maxLength: 100, - description: 'Unique snake_case name — referenced by submitters and audit rows', - group: 'Definition', - }), - - label: Field.text({ - label: 'Display Label', - required: true, - maxLength: 200, - group: 'Definition', - }), - - object_name: Field.text({ - label: 'Object', - required: true, - maxLength: 100, - description: 'Short object name this process governs', - group: 'Definition', - }), - - description: Field.textarea({ label: 'Description', required: false, group: 'Definition' }), - - active: Field.boolean({ - label: 'Active', - required: true, - defaultValue: false, - description: 'Only active processes are dispatched on submission', - group: 'Definition', - }), - - definition_json: Field.textarea({ - label: 'Definition', - required: true, - description: 'Serialised ApprovalProcess JSON (see @objectstack/spec/automation/approval)', - group: 'Definition', - }), - - created_at: Field.datetime({ - label: 'Created At', - required: true, - defaultValue: 'NOW()', - readonly: true, - group: 'System', - }), - - updated_at: Field.datetime({ label: 'Updated At', required: false, group: 'System' }), - }, - - indexes: [ - { fields: ['name'], unique: true }, - { fields: ['object_name'] }, - { fields: ['active', 'object_name'] }, - ], -}); diff --git a/packages/platform-objects/src/audit/sys-approval-request.object.ts b/packages/platform-objects/src/audit/sys-approval-request.object.ts index 66174fa38b..1370537178 100644 --- a/packages/platform-objects/src/audit/sys-approval-request.object.ts +++ b/packages/platform-objects/src/audit/sys-approval-request.object.ts @@ -5,19 +5,20 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** * sys_approval_request — Live approval instance. * - * Created when a user invokes `IApprovalService.submit(...)` and - * advanced as approvers act on each step. The row's lifecycle: + * ADR-0019: opened by a flow's **Approval node** when the run reaches it; the + * run suspends until a decision is recorded. The row's lifecycle: * - * `pending` → (per-step approvals) → `approved` | `rejected` + * `pending` → (per-approver decisions) → `approved` | `rejected` * `pending` → recalled by submitter → `recalled` * - * `current_step` / `current_step_index` mirror the index into the - * process's `steps[]` array so the engine can resume after a restart - * without re-deriving state from the audit log. + * `flow_run_id` / `flow_node_id` tie the request back to the suspended run so a + * decision can resume it; `current_step` mirrors the node id. `node_config_json` + * snapshots the Approval node config (approvers / behaviour) the request was + * opened with. * * `payload_json` captures a snapshot of the target record at submission - * time — used by the email/feed actions so they can render before the - * record is locked or changed. + * time — used by notifications so they can render before the record is + * locked or changed. * * @namespace sys */ @@ -92,10 +93,10 @@ export const SysApprovalRequest = ObjectSchema.create({ }), process_name: Field.text({ - label: 'Process', + label: 'Source', required: true, maxLength: 100, - description: 'sys_approval_process.name this request was opened against', + description: 'Origin of the request — `flow:` for node-driven approvals', group: 'Target', }), @@ -165,16 +166,6 @@ export const SysApprovalRequest = ObjectSchema.create({ group: 'State', }), - process_hash: Field.text({ - label: 'Process Hash', - required: false, - maxLength: 80, - readonly: true, - description: 'sha256 of the approval process body at submit time (ADR-0009 execution pinning). ' - + 'Resolved through sys_metadata_history so process upgrades do not affect in-flight requests.', - group: 'State', - }), - // ── ADR-0019: approval-as-flow-node correlation ────────────────── // When a request is opened by an Approval *node* (rather than a standalone // process), these tie it back to the suspended flow run so a decision can diff --git a/packages/plugins/plugin-approvals/src/action-executor.ts b/packages/plugins/plugin-approvals/src/action-executor.ts deleted file mode 100644 index cb4b669fc4..0000000000 --- a/packages/plugins/plugin-approvals/src/action-executor.ts +++ /dev/null @@ -1,313 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Approval Action Executor — M11.C15.B - * - * Pure dispatcher that runs the `ApprovalAction` items declared on - * `ApprovalProcess.onSubmit / onFinalApprove / onFinalReject / onRecall` - * and `ApprovalStep.onApprove / onReject`. - * - * Supported action types: - * - `field_update` — write `config.field = config.value` on the - * business record (under SYSTEM_CTX so the lock hook is bypassed). - * `config.value` may be a literal or `"$status"` / `"$now"` / - * `"$actor"` / `"$comment"` token resolved against the runtime - * context. - * - `inbox_notify` — insert one `sys_notification` row per target. - * `config.to` may be `'submitter' | 'pending_approvers'` or an - * explicit `string[]` of user ids. `config.title` / `config.body` - * interpolate `{record_id}`, `{object}`, `{status}`, `{step}`, - * `{actor}`, `{comment}`. - * - `webhook` — POST `config.body` (JSON) to `config.url`, - * fire-and-forget (caller awaits with timeout). Headers default - * to `Content-Type: application/json`. Failures are logged, not - * thrown, so a flaky receiver can't deadlock the approval flow. - * - * Unimplemented (logged + skipped): - * - `email_alert` — needs SMTP transport, later milestone. - * - `script` — needs sandboxed runner, later milestone. - * - `connector_action` — needs connector registry, later milestone. - */ - -import type { ApprovalEngine } from './approval-service.js'; - -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; - -export interface ActionLogger { - info?: (msg: string, meta?: any) => void; - warn?: (msg: string, meta?: any) => void; - error?: (msg: string, meta?: any) => void; - debug?: (msg: string, meta?: any) => void; -} - -const noopLogger: Required = { - info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, -}; - -/** Possible trigger points; passed to executors for tokenization. */ -export type ApprovalTrigger = - | 'submit' - | 'step_approve' - | 'final_approve' - | 'step_reject' - | 'final_reject' - | 'recall'; - -export interface ExecutionContext { - /** The trigger that caused these actions to fire. */ - trigger: ApprovalTrigger; - /** Approval process row (parsed `definition`). */ - process: any; - /** Approval request row (post-transition). */ - request: any; - /** Current step config (when applicable). */ - step?: any; - /** Business record (optional — looked up on demand if needed). */ - record?: any; - /** Actor whose decision triggered the action; for `submit` this is the submitter. */ - actorId?: string | null; - /** Comment passed with the decision. */ - comment?: string | null; -} - -/** Default fetch implementation — overridable for tests. */ -export type FetchLike = ( - input: any, - init?: any, -) => Promise<{ ok: boolean; status: number; statusText: string }>; - -export interface ExecuteActionsOptions { - engine: ApprovalEngine; - logger?: ActionLogger; - fetch?: FetchLike; - /** Maximum webhook duration in ms; default 5000. */ - webhookTimeoutMs?: number; -} - -const DEFAULT_WEBHOOK_TIMEOUT_MS = 5000; - -/** Public entry point — run an ordered list of actions. */ -export async function executeActions( - actions: any[] | undefined | null, - ctx: ExecutionContext, - opts: ExecuteActionsOptions, -): Promise { - if (!Array.isArray(actions) || actions.length === 0) return; - const log = { ...noopLogger, ...(opts.logger ?? {}) }; - for (const a of actions) { - try { - await runOne(a, ctx, opts, log); - } catch (err: any) { - // Approval actions must not crash the transition — log + continue. - log.error?.(`[approvals] action '${a?.type ?? ''}' failed: ${err?.message ?? err}`, { - action: a, trigger: ctx.trigger, request_id: ctx.request?.id, - }); - } - } -} - -async function runOne( - action: any, - ctx: ExecutionContext, - opts: ExecuteActionsOptions, - log: Required, -): Promise { - if (!action || typeof action !== 'object') return; - switch (action.type) { - case 'field_update': return runFieldUpdate(action, ctx, opts, log); - case 'inbox_notify': return runInboxNotify(action, ctx, opts, log); - case 'webhook': return runWebhook(action, ctx, opts, log); - case 'email_alert': - case 'script': - case 'connector_action': - log.warn?.(`[approvals] action type '${action.type}' is not implemented yet — skipping`, { - action_name: action.name, trigger: ctx.trigger, - }); - return; - default: - log.warn?.(`[approvals] unknown action type '${action.type}' — skipping`); - } -} - -// ── field_update ────────────────────────────────────────────────── - -async function runFieldUpdate( - action: any, - ctx: ExecutionContext, - opts: ExecuteActionsOptions, - log: Required, -): Promise { - const cfg = action.config ?? {}; - const field: string | undefined = cfg.field; - if (!field) { - log.warn?.('[approvals] field_update missing config.field'); - return; - } - const value = resolveValueToken(cfg.value, ctx); - const object = ctx.process?.object_name ?? ctx.process?.object; - const recordId = ctx.request?.record_id; - if (!object || !recordId) { - log.warn?.('[approvals] field_update missing object/record context'); - return; - } - await opts.engine.update( - object, - { id: recordId, [field]: value }, - { context: SYSTEM_CTX }, - ); - log.debug?.(`[approvals] field_update ${object}/${recordId} set ${field}`, { value }); -} - -/** Resolve `$status`, `$now`, `$actor`, `$comment` or literal value. */ -function resolveValueToken(raw: unknown, ctx: ExecutionContext): unknown { - if (typeof raw !== 'string') return raw; - switch (raw) { - case '$status': return ctx.request?.status ?? null; - case '$now': return new Date().toISOString(); - case '$actor': return ctx.actorId ?? null; - case '$comment': return ctx.comment ?? null; - case '$step': return ctx.request?.current_step ?? null; - case '$request_id': return ctx.request?.id ?? null; - default: return raw; - } -} - -// ── inbox_notify ────────────────────────────────────────────────── - -function interpolate(template: string, ctx: ExecutionContext): string { - if (typeof template !== 'string') return template as any; - return template - .replace(/\{record_id\}/g, String(ctx.request?.record_id ?? '')) - .replace(/\{object\}/g, String(ctx.process?.object_name ?? ctx.process?.object ?? '')) - .replace(/\{status\}/g, String(ctx.request?.status ?? '')) - .replace(/\{step\}/g, String(ctx.request?.current_step ?? '')) - .replace(/\{actor\}/g, String(ctx.actorId ?? '')) - .replace(/\{comment\}/g, String(ctx.comment ?? '')) - .replace(/\{process\}/g, String(ctx.process?.name ?? '')); -} - -async function runInboxNotify( - action: any, - ctx: ExecutionContext, - opts: ExecuteActionsOptions, - log: Required, -): Promise { - const cfg = action.config ?? {}; - const recipients = resolveRecipients(cfg.to, ctx); - if (recipients.length === 0) { - log.debug?.('[approvals] inbox_notify resolved no recipients — skipping'); - return; - } - const title = interpolate(cfg.title ?? 'Approval update', ctx); - const body = interpolate(cfg.body ?? '', ctx); - // sys_notification.type is a select with a fixed enum; 'system' is the - // safe default. Callers may override via cfg.notificationType but must - // pick a value the schema accepts. - const type = String(cfg.notificationType ?? 'system'); - const rawLink = cfg.link - ? interpolate(String(cfg.link), ctx) - : `/console/system/approvals?requestId=${encodeURIComponent(ctx.request?.id ?? '')}`; - // sys_notification.url is a URL field — only forward absolute URLs. - // Relative deep-links (`/system/approvals`) get stripped to satisfy - // validation; the recipient can still navigate via the source linkage. - const url = /^https?:\/\//i.test(rawLink) ? rawLink : null; - const now = new Date().toISOString(); - - for (const recipient of recipients) { - try { - await opts.engine.insert( - 'sys_notification', - { - id: `notif_${cryptoRandom()}`, - recipient_id: String(recipient), - type, - title, - body, - url, - is_read: false, - source_object: ctx.process?.object_name ?? ctx.process?.object ?? null, - source_id: ctx.request?.record_id ?? null, - created_at: now, - updated_at: now, - }, - { context: SYSTEM_CTX }, - ); - } catch (err: any) { - // Notification persistence is best-effort. - log.warn?.(`[approvals] inbox_notify insert failed for ${recipient}: ${err?.message ?? err}`); - } - } -} - -function resolveRecipients(to: unknown, ctx: ExecutionContext): string[] { - if (Array.isArray(to)) return to.map(String).filter(Boolean); - if (typeof to === 'string') { - if (to === 'submitter') return ctx.request?.submitter_id ? [String(ctx.request.submitter_id)] : []; - if (to === 'pending_approvers') { - const list = ctx.request?.pending_approvers ?? []; - if (Array.isArray(list)) return list.map(String).filter(Boolean); - if (typeof list === 'string') return list.split(',').map(s => s.trim()).filter(Boolean); - return []; - } - // Fall through: literal user id. - return [to]; - } - return []; -} - -function cryptoRandom(): string { - const g: any = globalThis as any; - if (g.crypto?.randomUUID) return g.crypto.randomUUID(); - return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`; -} - -// ── webhook ────────────────────────────────────────────────────── - -async function runWebhook( - action: any, - ctx: ExecutionContext, - opts: ExecuteActionsOptions, - log: Required, -): Promise { - const cfg = action.config ?? {}; - const url: string | undefined = cfg.url; - if (!url) { - log.warn?.('[approvals] webhook missing config.url'); - return; - } - const fetchImpl: FetchLike = opts.fetch ?? (globalThis as any).fetch; - if (!fetchImpl) { - log.warn?.('[approvals] webhook skipped — no fetch implementation available'); - return; - } - const timeoutMs = opts.webhookTimeoutMs ?? DEFAULT_WEBHOOK_TIMEOUT_MS; - const headers = { 'Content-Type': 'application/json', ...(cfg.headers ?? {}) }; - const payload = { - trigger: ctx.trigger, - request: ctx.request, - step: ctx.step ? { name: ctx.step.name, index: ctx.request?.current_step_index } : null, - actor_id: ctx.actorId ?? null, - comment: ctx.comment ?? null, - process_name: ctx.process?.name, - object: ctx.process?.object_name ?? ctx.process?.object, - ...(cfg.body && typeof cfg.body === 'object' ? cfg.body : {}), - }; - // Manual timeout — works in Node 18+ without AbortController dependency. - const controller = (globalThis as any).AbortController ? new (globalThis as any).AbortController() : null; - const timer = setTimeout(() => controller?.abort(), timeoutMs); - try { - const res = await fetchImpl(url, { - method: cfg.method ?? 'POST', - headers, - body: JSON.stringify(payload), - signal: controller?.signal, - }); - if (!res.ok) { - log.warn?.(`[approvals] webhook ${url} → ${res.status} ${res.statusText}`); - } - } catch (err: any) { - log.warn?.(`[approvals] webhook ${url} failed: ${err?.message ?? err}`); - } finally { - clearTimeout(timer); - } -} diff --git a/packages/plugins/plugin-approvals/src/approval-node.test.ts b/packages/plugins/plugin-approvals/src/approval-node.test.ts index 6d2a0f07bf..6356205bd5 100644 --- a/packages/plugins/plugin-approvals/src/approval-node.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-node.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { AutomationEngine } from '@objectstack/service-automation'; import { ApprovalService } from './approval-service.js'; -import { registerApprovalNode, decideApprovalNode } from './approval-node.js'; +import { registerApprovalNode } from './approval-node.js'; const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as any; @@ -83,6 +83,8 @@ describe('Approval node bridge (ADR-0019)', () => { automation = new AutomationEngine(noopLogger as any); fake = makeFakeEngine(); service = new ApprovalService({ engine: fake as any, logger: noopLogger }); + // The contract `decide()` resumes via the attached automation surface. + service.attachAutomation(automation); registerApprovalNode(automation, service, noopLogger); // A terminal "mark" node records which branch ran. automation.registerNodeExecutor({ @@ -127,8 +129,7 @@ describe('Approval node bridge (ADR-0019)', () => { }); const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0]; - const out = await decideApprovalNode(automation, service, request.id, - { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX, noopLogger); + const out = await service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX); expect(out).toMatchObject({ finalized: true, decision: 'approve', resumed: true }); expect(marks).toEqual(['on_approved']); @@ -144,8 +145,7 @@ describe('Approval node bridge (ADR-0019)', () => { await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } }); const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0]; - const out = await decideApprovalNode(automation, service, request.id, - { decision: 'reject', actorId: 'u1' }, SYSTEM_CTX, noopLogger); + const out = await service.decide(request.id, { decision: 'reject', actorId: 'u1' }, SYSTEM_CTX); expect(out).toMatchObject({ finalized: true, decision: 'reject', resumed: true }); expect(marks).toEqual(['on_rejected']); @@ -159,14 +159,12 @@ describe('Approval node bridge (ADR-0019)', () => { await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } }); const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0]; - const first = await decideApprovalNode(automation, service, request.id, - { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX, noopLogger); + const first = await service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX); expect(first.finalized).toBe(false); expect(first.resumed).toBe(false); expect(marks).toHaveLength(0); - const second = await decideApprovalNode(automation, service, request.id, - { decision: 'approve', actorId: 'u2' }, SYSTEM_CTX, noopLogger); + const second = await service.decide(request.id, { decision: 'approve', actorId: 'u2' }, SYSTEM_CTX); expect(second.finalized).toBe(true); expect(second.resumed).toBe(true); expect(marks).toEqual(['on_approved']); diff --git a/packages/plugins/plugin-approvals/src/approval-node.ts b/packages/plugins/plugin-approvals/src/approval-node.ts index 7ad0ce4b1c..cf88b954db 100644 --- a/packages/plugins/plugin-approvals/src/approval-node.ts +++ b/packages/plugins/plugin-approvals/src/approval-node.ts @@ -9,8 +9,8 @@ * 1. On entry the node opens a `sys_approval_request` (reusing the mature * approver-resolution / audit / lock / status-mirror machinery) and returns * `{ suspend: true }` — the engine persists the run and stops traversal. - * 2. A decision (`decideApprovalNode`) finalizes the request and resumes the - * run down the matching `approve` / `reject` out-edge. + * 2. A decision (`ApprovalService.decide`) finalizes the request and resumes + * the run down the matching `approve` / `reject` out-edge. * * The approval *state* (request/action rows) stays first-class and owned by this * plugin — a flow-run log can't drive an inbox / recall / audit. Only the @@ -21,7 +21,6 @@ import { defineActionDescriptor, ApprovalNodeConfigSchema, APPROVAL_NODE_TYPE, - APPROVAL_BRANCH_LABELS, type ApprovalNodeConfig, } from '@objectstack/spec/automation'; import type { SharingExecutionContext } from '@objectstack/spec/contracts'; @@ -125,39 +124,3 @@ export function registerApprovalNode( logger?.info?.('[approvals] approval node executor registered'); } - -/** - * Record a decision on a node-driven approval request and, when it finalizes, - * resume the suspended flow run down the matching branch. Returns the service - * decision result so callers (API routes) can surface request state. - */ -export async function decideApprovalNode( - automation: ApprovalAutomationSurface, - service: ApprovalService, - requestId: string, - input: { decision: 'approve' | 'reject'; actorId: string; comment?: string }, - context: SharingExecutionContext, - logger?: MinimalLogger, -): Promise<{ requestId: string; finalized: boolean; decision: 'approve' | 'reject'; resumed: boolean }> { - const result = await service.decideNode(requestId, input, context); - - let resumed = false; - if (result.finalized && result.runId && typeof automation.resume === 'function') { - const branchLabel = result.decision === 'approve' - ? APPROVAL_BRANCH_LABELS.approve - : APPROVAL_BRANCH_LABELS.reject; - try { - await automation.resume(result.runId, { - branchLabel, - output: { decision: result.decision, requestId }, - }); - resumed = true; - } catch (err: any) { - logger?.warn?.('[approvals] resume after decision failed', { - request: requestId, run: result.runId, error: err?.message ?? String(err), - }); - } - } - - return { requestId, finalized: result.finalized, decision: result.decision, resumed }; -} diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index 55b96c56eb..e9589a285c 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -1,13 +1,24 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +/** + * Node-era approval service tests (ADR-0019). + * + * Approval is a flow node — there is no standalone process engine. These tests + * exercise the service directly: opening a node-driven request, recording + * decisions (first_response / unanimous), the public `decide()` resume bridge, + * the read API, and the global record-lock hook. + */ + import { describe, it, expect, beforeEach } from 'vitest'; import { ApprovalService } from './approval-service.js'; +import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js'; interface FakeRow { [k: string]: any } function makeFakeEngine() { const tables: Record = {}; const ensure = (n: string) => (tables[n] ??= []); + const hooks: Record any | Promise; object?: string | string[]; packageId?: string }>> = {}; function matches(row: FakeRow, filter: any): boolean { if (!filter || typeof filter !== 'object') return true; @@ -28,6 +39,7 @@ function makeFakeEngine() { return { _tables: tables, + _hooks: hooks, async find(object: string, options?: any) { const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where)); if (options?.orderBy?.[0]) { @@ -60,41 +72,57 @@ function makeFakeEngine() { if (i >= 0) table.splice(i, 1); return { id }; }, + // ── hook surface (for the record-lock hook) ── + registerHook(event: string, handler: (ctx: any) => any, options?: any) { + (hooks[event] ??= []).push({ handler, object: options?.object, packageId: options?.packageId }); + }, + unregisterHooksByPackage(packageId: string): number { + let n = 0; + for (const ev of Object.keys(hooks)) { + const before = hooks[ev].length; + hooks[ev] = hooks[ev].filter(h => h.packageId !== packageId); + n += before - hooks[ev].length; + } + return n; + }, + async fire(event: string, ctx: any) { + for (const h of hooks[event] ?? []) { + if (h.object) { + const objs = Array.isArray(h.object) ? h.object : [h.object]; + if (!objs.includes(ctx.object)) continue; + } + await h.handler(ctx); + } + }, }; } -const CTX = { userId: 'u1', tenantId: 't1', roles: [], permissions: [] }; -const SYS = { isSystem: true, roles: [], permissions: [] }; +const CTX = { userId: 'u1', tenantId: 't1', roles: [], permissions: [] } as any; +const SYS = { isSystem: true, roles: [], permissions: [] } as any; -function singleStep(approvers: string[], behavior: 'first_response' | 'unanimous' = 'first_response') { +function nodeConfig(approvers: string[], extra: Record = {}) { return { - name: 'proc', - label: 'Proc', - object: 'opportunity', - active: true, - steps: [{ - name: 'sales_manager', - label: 'Sales Manager', - approvers: approvers.map(v => ({ type: 'user' as const, value: v })), - behavior, - }], + approvers: approvers.map(v => ({ type: 'user' as const, value: v })), + behavior: 'first_response' as const, + lockRecord: true, + ...extra, }; } -function multiStep() { +function openInput(approvers: string[], extra: Record = {}, configExtra: Record = {}) { return { - name: 'proc', - label: 'Proc', object: 'opportunity', - active: true, - steps: [ - { name: 'step1', label: 'Step 1', approvers: [{ type: 'user' as const, value: 'u1' }], behavior: 'first_response' }, - { name: 'step2', label: 'Step 2', approvers: [{ type: 'user' as const, value: 'u2' }], behavior: 'first_response', rejectionBehavior: 'back_to_previous' as const }, - ], + recordId: 'opp1', + runId: 'run_1', + nodeId: 'approve_step', + flowName: 'deal_approval', + config: nodeConfig(approvers, configExtra), + record: { id: 'opp1', amount: 100 }, + ...extra, }; } -describe('ApprovalService', () => { +describe('ApprovalService (node era)', () => { let engine: ReturnType; let svc: ApprovalService; let n = 0; @@ -105,342 +133,215 @@ describe('ApprovalService', () => { n = 0; svc = new ApprovalService({ engine: engine as any, - // Ensure strictly increasing timestamps so created_at sort is deterministic. clock: { now: () => new Date(baseTime + (n++) * 1000) }, }); }); - // ── Process CRUD ─────────────────────────────────────────────── - - it('defineProcess: creates with generated id and validates JSON', async () => { - const r = await svc.defineProcess({ - name: 'proc', label: 'P', object: 'opportunity', - definition: singleStep(['u9']), - }, CTX); - expect(r.id).toMatch(/^apv_/); - expect(r.active).toBe(true); - expect(engine._tables['sys_approval_process'].length).toBe(1); - expect(engine._tables['sys_approval_process'][0].definition_json).toContain('sales_manager'); - }); + // ── openNodeRequest ───────────────────────────────────────────── - it('defineProcess: upserts when name matches', async () => { - const a = await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - const b = await svc.defineProcess({ name: 'proc', label: 'P2', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - expect(b.id).toBe(a.id); - expect(b.label).toBe('P2'); - expect(engine._tables['sys_approval_process'].length).toBe(1); + it('openNodeRequest: creates a pending request + submit action with flow correlation', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + expect(req.status).toBe('pending'); + expect(req.process_name).toBe('flow:deal_approval'); + expect(req.flow_run_id).toBe('run_1'); + expect(req.flow_node_id).toBe('approve_step'); + expect(req.pending_approvers).toEqual(['u9']); + expect(engine._tables['sys_approval_request']).toHaveLength(1); + expect(engine._tables['sys_approval_action'][0].action).toBe('submit'); }); - it('defineProcess: rejects invalid definition', async () => { - await expect(svc.defineProcess({ - name: 'proc', label: 'P', object: 'opportunity', - definition: { name: 'proc', label: 'P', object: 'opportunity', steps: [] }, - }, CTX)).rejects.toThrow(/VALIDATION_FAILED/); + it('openNodeRequest: snapshots the node config on the row', async () => { + await svc.openNodeRequest(openInput(['u9']), CTX); + const raw = engine._tables['sys_approval_request'][0]; + expect(JSON.parse(raw.node_config_json)).toMatchObject({ behavior: 'first_response', lockRecord: true }); }); - it('listProcesses({activeOnly:true}) filters', async () => { - await svc.defineProcess({ name: 'proc_a', label: 'A', object: 'opportunity', active: true, definition: { ...singleStep(['u1']), name: 'proc_a' } }, CTX); - await svc.defineProcess({ name: 'proc_b', label: 'B', object: 'opportunity', active: false, definition: { ...singleStep(['u1']), name: 'proc_b', active: false } }, CTX); - const active = await svc.listProcesses({ activeOnly: true }, CTX); - expect(active.length).toBe(1); - expect(active[0].name).toBe('proc_a'); + it('openNodeRequest: deduplicates a pending request per (object, record)', async () => { + await svc.openNodeRequest(openInput(['u9']), CTX); + await expect(svc.openNodeRequest(openInput(['u9'], { runId: 'run_2' }), CTX)) + .rejects.toThrow(/DUPLICATE_REQUEST/); }); - it('getProcess by name then id; deleteProcess removes row', async () => { - const r = await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - expect((await svc.getProcess('proc', CTX))?.id).toBe(r.id); - expect((await svc.getProcess(r.id, CTX))?.name).toBe('proc'); - await svc.deleteProcess('proc', CTX); - expect(engine._tables['sys_approval_process'].length).toBe(0); + it('openNodeRequest: requires object, recordId, runId', async () => { + await expect(svc.openNodeRequest(openInput(['u9'], { object: '' }), CTX)).rejects.toThrow(/VALIDATION_FAILED/); + await expect(svc.openNodeRequest(openInput(['u9'], { recordId: '' }), CTX)).rejects.toThrow(/VALIDATION_FAILED/); + await expect(svc.openNodeRequest(openInput(['u9'], { runId: '' }), CTX)).rejects.toThrow(/VALIDATION_FAILED/); }); - // ── Submit ───────────────────────────────────────────────────── - - it('submit: happy path → creates request + submit action + pending_approvers', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'u1' }, CTX); - expect(req.status).toBe('pending'); - expect(req.pending_approvers).toEqual(['u9']); - expect(req.current_step).toBe('sales_manager'); - expect(engine._tables['sys_approval_action'].length).toBe(1); - expect(engine._tables['sys_approval_action'][0].action).toBe('submit'); + it('openNodeRequest: mirrors status onto the business record when configured', async () => { + engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }]; + await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX); + expect(engine._tables['opportunity'][0].approval_status).toBe('pending'); }); - it('submit: deduplicates pending requests', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - await expect(svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX)) - .rejects.toThrow(/DUPLICATE_REQUEST/); - }); + // ── decideNode ────────────────────────────────────────────────── - it('submit: throws when no active process', async () => { - await expect(svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX)) - .rejects.toThrow(/NO_ACTIVE_PROCESS/); + it('decideNode: first_response approve finalizes immediately', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + const out = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS); + expect(out.finalized).toBe(true); + expect(out.decision).toBe('approve'); + expect(out.runId).toBe('run_1'); + expect(out.nodeId).toBe('approve_step'); + expect(out.request.status).toBe('approved'); }); - // ── Approve ──────────────────────────────────────────────────── - - it('approve single step → finalized=true and status approved', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - const out = await svc.approve(req.id, { actorId: 'u9' }, CTX); + it('decideNode: reject finalizes as rejected', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + const out = await svc.decideNode(req.id, { decision: 'reject', actorId: 'u9', comment: 'no' }, SYS); expect(out.finalized).toBe(true); - expect(out.request.status).toBe('approved'); - expect(out.request.completed_at).toBeTruthy(); + expect(out.request.status).toBe('rejected'); }); - it('approve multi step → advances to next step', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: multiStep() }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - const out1 = await svc.approve(req.id, { actorId: 'u1' }, CTX); - expect(out1.finalized).toBe(false); - expect(out1.request.current_step).toBe('step2'); - expect(out1.request.current_step_index).toBe(1); - expect(out1.request.pending_approvers).toEqual(['u2']); - const out2 = await svc.approve(req.id, { actorId: 'u2' }, CTX); - expect(out2.finalized).toBe(true); - expect(out2.request.status).toBe('approved'); + it('decideNode: unanimous holds until every approver acts', async () => { + const req = await svc.openNodeRequest(openInput(['u1', 'u2'], {}, { behavior: 'unanimous' }), CTX); + const first = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS); + expect(first.finalized).toBe(false); + expect(first.request.pending_approvers).toEqual(['u2']); + const second = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS); + expect(second.finalized).toBe(true); + expect(second.request.status).toBe('approved'); }); - it('approve unanimous: first vote not final, second vote finalizes', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u1', 'u2'], 'unanimous') }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - const a = await svc.approve(req.id, { actorId: 'u1' }, CTX); - expect(a.finalized).toBe(false); - expect(a.request.pending_approvers).toEqual(['u2']); - const b = await svc.approve(req.id, { actorId: 'u2' }, CTX); - expect(b.finalized).toBe(true); - expect(b.request.status).toBe('approved'); + it('decideNode: blocks a non-approver in a non-system context', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + await expect( + svc.decideNode(req.id, { decision: 'approve', actorId: 'mallory' }, { isSystem: false, roles: [], permissions: [] } as any), + ).rejects.toThrow(/FORBIDDEN/); }); - it('approve by non-pending approver → FORBIDDEN', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - await expect(svc.approve(req.id, { actorId: 'mallory' }, CTX)).rejects.toThrow(/FORBIDDEN/); + it('decideNode: rejects a decision on a non-pending request', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS); + await expect(svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS)).rejects.toThrow(/INVALID_STATE/); }); - it('approve when not pending → INVALID_STATE', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - await svc.approve(req.id, { actorId: 'u9' }, CTX); - await expect(svc.approve(req.id, { actorId: 'u9' }, SYS)).rejects.toThrow(/INVALID_STATE/); + it('decideNode: mirrors the terminal status onto the business record', async () => { + engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }]; + const req = await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX); + await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS); + expect(engine._tables['opportunity'][0].approval_status).toBe('approved'); }); - // ── Reject ───────────────────────────────────────────────────── + // ── decide(): public contract + resume bridge ─────────────────── - it('reject default → finalized rejected', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - const out = await svc.reject(req.id, { actorId: 'u9', comment: 'no' }, CTX); + it('decide: resumes the owning run down the matching branch on finalize', async () => { + const resumed: any[] = []; + svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } }); + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + const out = await svc.decide(req.id, { decision: 'approve', actorId: 'u9' }, SYS); expect(out.finalized).toBe(true); - expect(out.request.status).toBe('rejected'); + expect(out.resumed).toBe(true); + expect(out.runId).toBe('run_1'); + expect(resumed).toHaveLength(1); + expect(resumed[0]).toMatchObject({ runId: 'run_1', signal: { branchLabel: 'approve' } }); }); - it('reject back_to_previous: advances back', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: multiStep() }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - await svc.approve(req.id, { actorId: 'u1' }, CTX); // advance to step2 - const out = await svc.reject(req.id, { actorId: 'u2' }, CTX); + it('decide: does not resume while a unanimous request is still pending', async () => { + const resumed: any[] = []; + svc.attachAutomation({ async resume(runId) { resumed.push(runId); } }); + const req = await svc.openNodeRequest(openInput(['u1', 'u2'], {}, { behavior: 'unanimous' }), CTX); + const out = await svc.decide(req.id, { decision: 'approve', actorId: 'u1' }, SYS); expect(out.finalized).toBe(false); - expect(out.request.current_step_index).toBe(0); - expect(out.request.current_step).toBe('step1'); - expect(out.request.pending_approvers).toEqual(['u1']); + expect(out.resumed).toBe(false); + expect(resumed).toHaveLength(0); }); - // ── Recall ───────────────────────────────────────────────────── - - it('recall by submitter → status recalled', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'u1' }, CTX); - const out = await svc.recall(req.id, { actorId: 'u1' }, CTX); + it('decide: finalizes even when no automation is attached (resumed=false)', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + const out = await svc.decide(req.id, { decision: 'reject', actorId: 'u9' }, SYS); expect(out.finalized).toBe(true); - expect(out.request.status).toBe('recalled'); + expect(out.resumed).toBe(false); }); - it('recall by non-submitter → FORBIDDEN', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'u1' }, CTX); - await expect(svc.recall(req.id, { actorId: 'mallory' }, CTX)).rejects.toThrow(/FORBIDDEN/); - }); - - // ── Listing ──────────────────────────────────────────────────── + // ── read API ──────────────────────────────────────────────────── - it('listRequests: filters by approverId via post-filter', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - await svc.submit({ object: 'opportunity', recordId: 'opp2' }, CTX); - const mine = await svc.listRequests({ approverId: 'u9' }, CTX); - expect(mine.length).toBe(2); - const empty = await svc.listRequests({ approverId: 'noone' }, CTX); - expect(empty.length).toBe(0); + it('listRequests: filters by approver and status', async () => { + await svc.openNodeRequest(openInput(['u9']), CTX); + const pending = await svc.listRequests({ status: 'pending', approverId: 'u9' }, SYS); + expect(pending).toHaveLength(1); + const none = await svc.listRequests({ approverId: 'nobody' }, SYS); + expect(none).toHaveLength(0); }); - it('listActions: returns rows ordered by created_at ASC', async () => { - await svc.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: singleStep(['u9']) }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - await svc.approve(req.id, { actorId: 'u9' }, CTX); - const actions = await svc.listActions(req.id, CTX); + it('listActions: returns the audit trail for a request', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS); + const actions = await svc.listActions(req.id, SYS); expect(actions.map(a => a.action)).toEqual(['submit', 'approve']); }); - // ── Graph expansion (M10.17.1) ─────────────────────────────────── - - it('approver type=team expands flat sys_team_member', async () => { - engine._tables.sys_team = [{ id: 'sales', name: 'sales', organization_id: 't1' }]; - engine._tables.sys_team_member = [ - { id: 'tm1', team_id: 'sales', user_id: 'alice' }, - { id: 'tm2', team_id: 'sales', user_id: 'bob' }, - ]; - await svc.defineProcess({ - name: 'team_proc', label: 'TeamProc', object: 'opportunity', - definition: { - name: 'team_proc', label: 'TeamProc', object: 'opportunity', active: true, - steps: [{ name: 's1', label: 'S1', approvers: [{ type: 'team', value: 'sales' }] }], - }, - }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - expect(req.pending_approvers?.sort()).toEqual(['alice', 'bob']); + it('getRequest: returns null for an unknown id', async () => { + expect(await svc.getRequest('nope', SYS)).toBeNull(); }); +}); - it('approver type=department walks parent_department_id (BFS)', async () => { - engine._tables.sys_department = [ - { id: 'emea', name: 'EMEA', parent_department_id: null, organization_id: 't1', active: true }, - { id: 'emea_sales', name: 'EMEA Sales', parent_department_id: 'emea', organization_id: 't1', active: true }, - { id: 'emea_sales_uk', name: 'EMEA Sales UK', parent_department_id: 'emea_sales', organization_id: 't1', active: true }, - ]; - engine._tables.sys_department_member = [ - { id: 'dm1', department_id: 'emea', user_id: 'eva' }, - { id: 'dm2', department_id: 'emea_sales_uk', user_id: 'alice' }, - ]; - await svc.defineProcess({ - name: 'dept_proc', label: 'DeptProc', object: 'opportunity', - definition: { - name: 'dept_proc', label: 'DeptProc', object: 'opportunity', active: true, - steps: [{ name: 's1', label: 'S1', approvers: [{ type: 'department', value: 'emea' }] }], - }, - }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - expect(req.pending_approvers?.sort()).toEqual(['alice', 'eva']); - }); +describe('record-lock hook (node era)', () => { + let engine: ReturnType; + let svc: ApprovalService; + let n = 0; + const baseTime = new Date('2026-01-15T10:00:00Z').getTime(); - it('approver type=department with no rows falls back to prefixed literal', async () => { - await svc.defineProcess({ - name: 'dept_proc2', label: 'DeptProc', object: 'opportunity', - definition: { - name: 'dept_proc2', label: 'DeptProc', object: 'opportunity', active: true, - steps: [{ name: 's1', label: 'S1', approvers: [{ type: 'department', value: 'unknown' }] }], - }, - }, CTX); - const req = await svc.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - expect(req.pending_approvers).toEqual(['department:unknown']); + beforeEach(async () => { + engine = makeFakeEngine(); + n = 0; + svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } }); + bindApprovalLockHook(engine as any); + await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX); }); - // ── ADR-0009 execution pinning ───────────────────────────────── - - describe('execution pinning (ADR-0009)', () => { - // Minimal fake metadata repo: keeps a map of (name → versions[]) where - // each version has a hash. Mirrors MetadataRepository.get/getByHash. - function makeFakeMetadataRepo() { - const versions = new Map(); - return { - store(name: string, body: any) { - const hash = `sha256:${name}_${(versions.get(name)?.length ?? 0) + 1}`; - const list = versions.get(name) ?? []; - list.push({ hash, body }); - versions.set(name, list); - }, - async get(ref: any) { - const list = versions.get(ref.name); - if (!list?.length) return null; - const head = list[list.length - 1]; - return { ref, hash: head.hash, body: head.body, seq: list.length, version: list.length, parentHash: null }; - }, - async getByHash(ref: any, hash: string) { - const list = versions.get(ref.name); - const found = list?.find(v => v.hash === hash); - return found ? { ref, hash: found.hash, body: found.body, seq: 1, version: 1, parentHash: null } : null; - }, - async put() { throw new Error('not implemented'); }, - async delete() { throw new Error('not implemented'); }, - list() { return (async function* () {})(); }, - async *history() {}, - watch() { return () => {}; }, - async start() {}, - async stop() {}, - } as any; - } + it('blocks a user edit to a record with a pending approval', async () => { + await expect( + engine.fire('beforeUpdate', { + object: 'opportunity', + input: { id: 'opp1', data: { amount: 200 } }, + session: { isSystem: false, roles: [], userId: 'u1' }, + }), + ).rejects.toThrow(/RECORD_LOCKED/); + }); - it('submit records process_hash when metadataRepo is wired', async () => { - const repo = makeFakeMetadataRepo(); - const v1 = multiStep(); - repo.store('proc', v1); + it('allows a status-mirror write (only the approvalStatusField changes)', async () => { + await expect( + engine.fire('beforeUpdate', { + object: 'opportunity', + input: { id: 'opp1', data: { approval_status: 'approved' } }, + session: { isSystem: false, roles: [] }, + }), + ).resolves.toBeUndefined(); + }); - const pinned = new ApprovalService({ - engine: engine as any, - clock: { now: () => new Date(baseTime + (n++) * 1000) }, - metadataRepo: repo, - }); - await pinned.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: v1 }, CTX); + it('allows engine self-writes (system session)', async () => { + await expect( + engine.fire('beforeUpdate', { + object: 'opportunity', + input: { id: 'opp1', data: { amount: 200 } }, + session: { isSystem: true, roles: [] }, + }), + ).resolves.toBeUndefined(); + }); - const req = await pinned.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - expect(req.process_hash).toMatch(/^sha256:proc_1$/); - }); + it('allows an admin override', async () => { + await expect( + engine.fire('beforeUpdate', { + object: 'opportunity', + input: { id: 'opp1', data: { amount: 200 } }, + session: { isSystem: false, roles: ['admin'] }, + }), + ).resolves.toBeUndefined(); + }); - it('process upgrade after submit does NOT affect an in-flight request', async () => { - const repo = makeFakeMetadataRepo(); - const v1 = multiStep(); // 2 steps: u1 → u2 - repo.store('proc', v1); - - const pinned = new ApprovalService({ - engine: engine as any, - clock: { now: () => new Date(baseTime + (n++) * 1000) }, - metadataRepo: repo, - }); - await pinned.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: v1 }, CTX); - - // Submit the request — pinned to v1 (2 steps). - const req = await pinned.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - expect(req.pending_approvers).toEqual(['u1']); - - // After submit, the process gets a brand new third step appended. - const v2 = { - name: 'proc', label: 'P', object: 'opportunity', active: true, - steps: [ - ...v1.steps, - { name: 'step3', label: 'Step 3', approvers: [{ type: 'user' as const, value: 'u3' }], behavior: 'first_response' as const }, - ], - }; - repo.store('proc', v2); - // Also refresh the projection — simulates redeploy. - await pinned.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: v2 }, CTX); - - // Step 1 approver acts → request advances to step 2 (pinned v1, NOT v2). - const r1 = await pinned.approve(req.id, { actorId: 'u1' }, CTX); - expect(r1.request.current_step).toBe('step2'); - expect(r1.request.pending_approvers).toEqual(['u2']); - expect(r1.finalized).toBe(false); - - // Step 2 approver finalises → pinned process has only 2 steps, so - // the request becomes `approved` instead of advancing to v2's step3. - const r2 = await pinned.approve(req.id, { actorId: 'u2' }, CTX); - expect(r2.finalized).toBe(true); - expect(r2.request.status).toBe('approved'); - }); + it('does not lock records without a pending request', async () => { + await expect( + engine.fire('beforeUpdate', { + object: 'opportunity', + input: { id: 'other_record', data: { amount: 200 } }, + session: { isSystem: false, roles: [] }, + }), + ).resolves.toBeUndefined(); + }); - it('falls back to projection when metadataRepo has no head (e.g. defineProcess-only path)', async () => { - const repo = makeFakeMetadataRepo(); - // Note: repo has NO body for 'proc' — only the projection table does. - const pinned = new ApprovalService({ - engine: engine as any, - clock: { now: () => new Date(baseTime + (n++) * 1000) }, - metadataRepo: repo, - }); - await pinned.defineProcess({ name: 'proc', label: 'P', object: 'opportunity', definition: multiStep() }, CTX); - const req = await pinned.submit({ object: 'opportunity', recordId: 'opp1' }, CTX); - expect(req.process_hash).toBeUndefined(); - // approve still works through the fallback path. - const r = await pinned.approve(req.id, { actorId: 'u1' }, CTX); - expect(r.request.current_step).toBe('step2'); - }); + it('unbindAllHooks removes the lock hook', () => { + expect(unbindAllHooks(engine as any)).toBe(1); + expect(engine._hooks['beforeUpdate']).toHaveLength(0); }); }); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index e1acbbd75f..5e818cadd9 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -1,24 +1,33 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { ApprovalProcessSchema, type ApprovalNodeConfig } from '@objectstack/spec/automation'; +import { + APPROVAL_BRANCH_LABELS, + type ApprovalNodeConfig, +} from '@objectstack/spec/automation'; import type { IApprovalService, - ApprovalProcessRow, ApprovalRequestRow, ApprovalActionRow, ApprovalDecisionInput, ApprovalDecisionResult, ApprovalStatus, - DefineApprovalProcessInput, - SubmitApprovalInput, SharingExecutionContext, } from '@objectstack/spec/contracts'; -import type { MetadataRepository } from '@objectstack/metadata-core'; -import { executeActions, type ApprovalTrigger, type FetchLike } from './action-executor.js'; /** - * Narrow engine surface — keeps the service testable without booting - * a real ObjectQL kernel. + * Node-era approval runtime (ADR-0019). + * + * Approval is no longer a standalone engine — it is a **flow node**. A flow's + * Approval node opens a request via {@link ApprovalService.openNodeRequest} and + * the run suspends; a human decision via {@link ApprovalService.decide} + * finalises the request and resumes the owning run down the matching + * `approve` / `reject` edge. + * + * This service owns the durable approval *state* — `sys_approval_request` / + * `sys_approval_action`, approver resolution (team / department / role / + * manager graph), and the optional status-field mirror — plus the decision + * API. It does not author processes, submit, or walk multi-step machinery + * anymore; that orchestration lives on the one automation engine. */ export interface ApprovalEngine { find(object: string, options?: any): Promise; @@ -29,6 +38,15 @@ export interface ApprovalEngine { export interface ApprovalClock { now(): Date } +/** + * Minimal automation surface the service uses to resume a suspended flow run + * once a decision finalises a node-driven request. Optional — attached by the + * plugin when an automation engine is present (see `approval-node.ts`). + */ +export interface ApprovalResumeSurface { + resume?(runId: string, signal?: { output?: Record; branchLabel?: string }): Promise; +} + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; function uid(prefix: string): string { @@ -51,26 +69,11 @@ function csvSplit(raw: unknown): string[] { return String(raw).split(',').map(s => s.trim()).filter(Boolean); } -function rowFromProcess(row: any): ApprovalProcessRow { - return { - id: String(row.id), - name: String(row.name ?? ''), - label: String(row.label ?? ''), - object_name: String(row.object_name ?? ''), - description: row.description ?? undefined, - active: row.active !== false, - definition: parseJson(row.definition_json, {}), - created_at: row.created_at ?? undefined, - updated_at: row.updated_at ?? undefined, - }; -} - function rowFromRequest(row: any): ApprovalRequestRow { return { id: String(row.id), organization_id: row.organization_id ?? undefined, process_name: String(row.process_name ?? ''), - process_hash: row.process_hash ?? undefined, object_name: String(row.object_name ?? ''), record_id: String(row.record_id ?? ''), submitter_id: row.submitter_id ?? undefined, @@ -80,6 +83,8 @@ function rowFromRequest(row: any): ApprovalRequestRow { current_step_index: row.current_step_index ?? undefined, pending_approvers: csvSplit(row.pending_approvers), payload: parseJson(row.payload_json, undefined), + flow_run_id: row.flow_run_id ?? undefined, + flow_node_id: row.flow_node_id ?? undefined, completed_at: row.completed_at ?? undefined, created_at: row.created_at ?? undefined, updated_at: row.updated_at ?? undefined, @@ -99,70 +104,45 @@ function rowFromAction(row: any): ApprovalActionRow { }; } -// Note: legacy synchronous `resolveApprovers` removed in M10.17.1 — replaced -// by the async `expandApprovers` member which routes through the team/dept -// graph tables (with prefixed-literal fallback for back-compat). - export interface ApprovalServiceOptions { engine: ApprovalEngine; clock?: ApprovalClock; logger?: { info?: (msg: any, ...rest: any[]) => void; warn?: (msg: any, ...rest: any[]) => void; error?: (msg: any, ...rest: any[]) => void; debug?: (msg: any, ...rest: any[]) => void }; - /** Optional fetch impl for `webhook` actions; defaults to global. */ - fetch?: FetchLike; - /** Webhook timeout in ms; default 5000. */ - webhookTimeoutMs?: number; /** - * Called after the process registry changes (defineProcess / deleteProcess). - * The plugin uses this to re-bind lifecycle hooks for auto-trigger / lock. + * Optional automation surface used to resume a suspended flow run when a + * decision finalises a request. Usually attached after construction via + * {@link ApprovalService.attachAutomation} once the automation engine is + * available. */ - onRegistryChange?: () => void | Promise; - /** - * Optional metadata repository for execution-pinned process resolution - * (ADR-0009). When provided: - * - * - `submit()` records the process body's sha256 on the request row. - * - `approve` / `reject` / `recall` resolve the pinned body via - * `MetadataRepository.getByHash` so process upgrades don't affect - * in-flight requests. - * - * When omitted, the service reads the current process from the - * `sys_approval_process` projection (pre-ADR-0009 behaviour). - */ - metadataRepo?: MetadataRepository; + automation?: ApprovalResumeSurface; } export class ApprovalService implements IApprovalService { private readonly engine: ApprovalEngine; private readonly clock: ApprovalClock; private readonly logger?: ApprovalServiceOptions['logger']; - private readonly fetchImpl?: FetchLike; - private readonly webhookTimeoutMs?: number; - private readonly onRegistryChange?: () => void | Promise; - private readonly metadataRepo?: MetadataRepository; + private automation?: ApprovalResumeSurface; constructor(opts: ApprovalServiceOptions) { this.engine = opts.engine; this.clock = opts.clock ?? { now: () => new Date() }; this.logger = opts.logger; - this.fetchImpl = opts.fetch; - this.webhookTimeoutMs = opts.webhookTimeoutMs; - this.onRegistryChange = opts.onRegistryChange; - this.metadataRepo = opts.metadataRepo; + this.automation = opts.automation; } - /** Allow the plugin to attach a hook re-binding callback after construction. */ - setRegistryChangeHandler(handler: () => void | Promise): void { - (this as any).onRegistryChange = handler; + /** Attach (or replace) the automation surface used to resume flow runs. */ + attachAutomation(automation: ApprovalResumeSurface): void { + this.automation = automation; } /** - * Expand the approvers on a step into user IDs by querying the graph - * tables for `team:` / `department:` / `role:` / `manager:` approver - * types. Falls back to a prefixed literal (`type:value`) when graph - * lookups produce nothing — so existing test fixtures and approver - * flows that rely on substring matching keep working. + * Expand the approvers on an Approval node into user IDs by querying the + * graph tables for `team:` / `department:` / `role:` / `manager:` approver + * types. Falls back to a prefixed literal (`type:value`) when graph lookups + * produce nothing — so existing fixtures and flows that rely on substring + * matching keep working. * - * **Graph semantics (M10.17.1):** + * **Graph semantics:** * - `team` → flat members of `sys_team` (better-auth; no BFS) * - `department` → recursive BFS of `sys_department.parent_department_id` * → members of every descendant via `sys_department_member` @@ -281,547 +261,27 @@ export class ApprovalService implements IApprovalService { } catch { return null; } } - - private async notifyRegistryChanged(): Promise { - const cb = this.onRegistryChange ?? ((this as any).onRegistryChange as (() => void | Promise) | undefined); - if (!cb) return; - try { await cb(); } - catch (err: any) { this.logger?.warn?.('[approvals] onRegistryChange handler failed', { error: err?.message }); } - } - - /** - * Look up the HEAD checksum of an approval process from the metadata repo - * (ADR-0009). Returns null when no repo is wired, no metadata exists for - * the name, or the lookup fails — callers MUST treat null as "do not pin" - * and fall back to the projection table. - */ - private async resolveProcessHash(processName: string, organizationId?: string | null): Promise { - if (!this.metadataRepo) return null; - if (!processName) return null; - const orgRef = { org: organizationId || 'system', type: 'approval' as const, name: processName }; - try { - const head = await this.metadataRepo.get(orgRef); - return head?.hash ?? null; - } catch (err: any) { - this.logger?.debug?.('[approvals] metadataRepo.get failed', { name: processName, error: err?.message }); - return null; - } - } - - /** - * Resolve the approval process for an in-flight request, honouring - * ADR-0009 execution pinning when a `process_hash` is recorded. - * - * Resolution order: - * 1. If `req.process_hash` AND `metadataRepo` are set, try - * `getByHash` — return a row whose `definition` is the pinned body. - * 2. Otherwise (or on lookup failure) fall back to the current - * projection via `getProcess(req.process_name)`. - */ - private async loadProcessForRequest(req: ApprovalRequestRow, context: SharingExecutionContext): Promise { - const hash = req.process_hash; - if (hash && this.metadataRepo) { - const orgId = (req as any).organization_id ?? null; - const orgRef = { org: orgId || 'system', type: 'approval' as const, name: req.process_name }; - try { - const pinned = await this.metadataRepo.getByHash(orgRef, hash); - if (pinned?.body) { - // Use the pinned body for the definition; pull identity/state - // fields from the current projection if available so display - // labels and active-flag stay fresh. Synthesize if absent. - const current = await this.getProcess(req.process_name, context); - const body: any = pinned.body; - return { - id: current?.id ?? `pinned_${hash.slice(7, 19)}`, - name: req.process_name, - label: body.label ?? current?.label ?? req.process_name, - object_name: req.object_name, - description: body.description ?? current?.description, - active: current?.active ?? true, - definition: body, - created_at: current?.created_at, - updated_at: current?.updated_at, - }; - } - this.logger?.warn?.('[approvals] pinned process body not found; falling back to current', { - request: req.id, process: req.process_name, hash, - }); - } catch (err: any) { - this.logger?.warn?.('[approvals] getByHash failed; falling back to current', { - request: req.id, error: err?.message, - }); - } - } - return this.getProcess(req.process_name, context); - } - - /** Mirror request status onto `process.approvalStatusField` if configured. */ - private async syncStatusField(process: ApprovalProcessRow, request: ApprovalRequestRow): Promise { - const field = (process.definition as any)?.approvalStatusField; - if (!field) return; + /** Mirror a request status onto a business-object field, if configured. */ + private async mirrorStatusField(object: string, recordId: string, field: string, status: string): Promise { try { - await this.engine.update( - process.object_name, - { id: request.record_id, [field]: request.status }, - { context: SYSTEM_CTX }, - ); + await this.engine.update(object, { id: recordId, [field]: status }, { context: SYSTEM_CTX }); } catch (err: any) { - this.logger?.warn?.(`[approvals] syncStatusField failed: ${err?.message ?? err}`); - } - } - - /** Convenience wrapper that funnels every action invocation through the executor. */ - private async runActions( - actions: any[] | undefined | null, - trigger: ApprovalTrigger, - process: ApprovalProcessRow, - request: ApprovalRequestRow, - step: any | undefined, - actorId: string | null | undefined, - comment: string | null | undefined, - ): Promise { - if (!actions || actions.length === 0) return; - await executeActions(actions, { - trigger, - process: { ...process, object: process.object_name }, - request, - step, - actorId: actorId ?? null, - comment: comment ?? null, - }, { - engine: this.engine, - logger: this.logger, - fetch: this.fetchImpl, - webhookTimeoutMs: this.webhookTimeoutMs, - }); - } - - // ── Process definitions ────────────────────────────────────── - - async defineProcess(input: DefineApprovalProcessInput, _context: SharingExecutionContext): Promise { - if (!input.name) throw new Error('VALIDATION_FAILED: name is required'); - if (!input.label) throw new Error('VALIDATION_FAILED: label is required'); - if (!input.object) throw new Error('VALIDATION_FAILED: object is required'); - if (!input.definition) throw new Error('VALIDATION_FAILED: definition is required'); - - const parsed = ApprovalProcessSchema.safeParse(input.definition); - if (!parsed.success) { - const msg = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; '); - throw new Error(`VALIDATION_FAILED: ${msg}`); - } - - const now = this.clock.now().toISOString(); - const payload: any = { - name: input.name, - label: input.label, - object_name: input.object, - description: input.description ?? null, - active: input.active !== false, - definition_json: JSON.stringify(parsed.data), - updated_at: now, - }; - - // Upsert by name. - const existing = await this.engine.find('sys_approval_process', { - where: { name: input.name }, limit: 1, context: SYSTEM_CTX, - }); - if (Array.isArray(existing) && existing[0]) { - const id = existing[0].id; - await this.engine.update('sys_approval_process', { id, ...payload }, { context: SYSTEM_CTX }); - const row = rowFromProcess({ ...existing[0], ...payload, id }); - await this.notifyRegistryChanged(); - return row; - } - - const id = input.id ?? uid('apv'); - const row = { id, ...payload, created_at: now }; - await this.engine.insert('sys_approval_process', row, { context: SYSTEM_CTX }); - const out = rowFromProcess(row); - await this.notifyRegistryChanged(); - return out; - } - - async listProcesses( - filter: { object?: string; activeOnly?: boolean } | undefined, - _context: SharingExecutionContext, - ): Promise { - const f: any = {}; - if (filter?.object) f.object_name = filter.object; - if (filter?.activeOnly) f.active = true; - const rows = await this.engine.find('sys_approval_process', { - where: f, limit: 500, orderBy: [{ field: 'updated_at', direction: 'desc' }], context: SYSTEM_CTX, - }); - return Array.isArray(rows) ? rows.map(rowFromProcess) : []; - } - - async getProcess(idOrName: string, _context: SharingExecutionContext): Promise { - if (!idOrName) return null; - let rows = await this.engine.find('sys_approval_process', { - where: { id: idOrName }, limit: 1, context: SYSTEM_CTX, - }); - if (!Array.isArray(rows) || !rows[0]) { - rows = await this.engine.find('sys_approval_process', { - where: { name: idOrName }, limit: 1, context: SYSTEM_CTX, - }); - } - return Array.isArray(rows) && rows[0] ? rowFromProcess(rows[0]) : null; - } - - async deleteProcess(idOrName: string, context: SharingExecutionContext): Promise { - if (!idOrName) throw new Error('VALIDATION_FAILED: idOrName is required'); - const proc = await this.getProcess(idOrName, context); - if (!proc) return; - await this.engine.delete('sys_approval_process', { where: { id: proc.id }, context: SYSTEM_CTX }); - await this.notifyRegistryChanged(); - } - - // ── Requests ───────────────────────────────────────────────── - - async submit(input: SubmitApprovalInput, context: SharingExecutionContext): Promise { - if (!input.object) throw new Error('VALIDATION_FAILED: object is required'); - if (!input.recordId) throw new Error('VALIDATION_FAILED: recordId is required'); - - // Find active process for the object (or by name when supplied). - let process: ApprovalProcessRow | null = null; - if (input.processName) { - process = await this.getProcess(input.processName, context); - if (process && !process.active) { - throw new Error(`NO_ACTIVE_PROCESS: process '${input.processName}' is not active`); - } - } else { - const list = await this.listProcesses({ object: input.object, activeOnly: true }, context); - process = list[0] ?? null; - } - if (!process) { - throw new Error(`NO_ACTIVE_PROCESS: no active approval process for object '${input.object}'`); - } - - // De-duplicate: only one pending request per (object, record). - const existing = await this.engine.find('sys_approval_request', { - where: { object_name: input.object, record_id: input.recordId, status: 'pending' }, - limit: 1, context: SYSTEM_CTX, - }); - if (Array.isArray(existing) && existing[0]) { - throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`); - } - - const steps: any[] = process.definition?.steps ?? []; - if (steps.length === 0) { - throw new Error('VALIDATION_FAILED: process definition has no steps'); - } - const step0 = steps[0]; - const ctxOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? null; - const approvers = await this.expandApprovers(step0, input.payload, ctxOrg); - - const now = this.clock.now().toISOString(); - const id = uid('areq'); - const processHash = await this.resolveProcessHash(process.name, ctxOrg); - const row: any = { - id, - process_name: process.name, - process_hash: processHash, - object_name: input.object, - record_id: input.recordId, - submitter_id: input.submitterId ?? context.userId ?? null, - submitter_comment: input.comment ?? null, - status: 'pending', - current_step: step0.name, - current_step_index: 0, - pending_approvers: approvers.join(','), - payload_json: input.payload != null ? JSON.stringify(input.payload) : null, - organization_id: ctxOrg, - created_at: now, - updated_at: now, - }; - await this.engine.insert('sys_approval_request', row, { context: SYSTEM_CTX }); - - // Audit: submit. - await this.engine.insert('sys_approval_action', { - id: uid('aact'), - request_id: id, - organization_id: ctxOrg, - step_name: step0.name, - step_index: 0, - action: 'submit', - actor_id: input.submitterId ?? context.userId ?? null, - comment: input.comment ?? null, - created_at: now, - }, { context: SYSTEM_CTX }); - - const requestRow = rowFromRequest(row); - - // Phase B: status mirror + onSubmit actions. - await this.syncStatusField(process, requestRow); - const definition: any = process.definition ?? {}; - await this.runActions( - definition.onSubmit, - 'submit', - process, - requestRow, - step0, - input.submitterId ?? context.userId ?? null, - input.comment ?? null, - ); - - return requestRow; - } - - async listRequests( - filter: { - object?: string; - recordId?: string; - status?: ApprovalStatus | ApprovalStatus[]; - approverId?: string; - submitterId?: string; - } | undefined, - context: SharingExecutionContext, - ): Promise { - const f: any = {}; - if (filter?.object) f.object_name = filter.object; - if (filter?.recordId) f.record_id = filter.recordId; - if (filter?.submitterId) f.submitter_id = filter.submitterId; - // Tenant isolation: when a caller context carries a tenant identifier - // (organizationId / tenantId), scope the query to that tenant. SYSTEM - // callers (no tenant) see all rows. This prevents the bespoke endpoint - // from leaking other-tenant rows since we deliberately query with - // SYSTEM_CTX to bypass RLS on the engine (we need CSV substring match - // on pending_approvers which RLS can't model cleanly). - const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId; - if (tenantOrg) f.organization_id = tenantOrg; - // Status: when array, post-filter; when single, push into engine filter. - let statusFilter: ApprovalStatus[] | undefined; - if (Array.isArray(filter?.status)) statusFilter = filter!.status as ApprovalStatus[]; - else if (filter?.status) f.status = filter.status; - - const rows = await this.engine.find('sys_approval_request', { - where: f, limit: 500, orderBy: [{ field: 'updated_at', direction: 'desc' }], context: SYSTEM_CTX, - }); - let list = Array.isArray(rows) ? rows.map(rowFromRequest) : []; - if (statusFilter) list = list.filter(r => statusFilter!.includes(r.status)); - if (filter?.approverId) { - const target = filter.approverId; - list = list.filter(r => (r.pending_approvers ?? []).includes(target)); - } - return list; - } - - async getRequest(requestId: string, context: SharingExecutionContext): Promise { - if (!requestId) return null; - const where: any = { id: requestId }; - const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId; - if (tenantOrg) where.organization_id = tenantOrg; - const rows = await this.engine.find('sys_approval_request', { - where, limit: 1, context: SYSTEM_CTX, - }); - return Array.isArray(rows) && rows[0] ? rowFromRequest(rows[0]) : null; - } - - async approve(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise { - const req = await this.getRequest(requestId, context); - if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`); - if (req.status !== 'pending') throw new Error(`INVALID_STATE: request is ${req.status}`); - if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required'); - - if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) { - throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`); - } - - const process = await this.loadProcessForRequest(req, context); - if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`); - const steps: any[] = process.definition?.steps ?? []; - const stepIndex = req.current_step_index ?? 0; - const step = steps[stepIndex]; - if (!step) throw new Error(`INVALID_STATE: step index ${stepIndex} out of range`); - - const now = this.clock.now().toISOString(); - // Audit row first so unanimous tally sees it. - await this.engine.insert('sys_approval_action', { - id: uid('aact'), - request_id: req.id, - organization_id: (req as any).organization_id ?? null, - step_name: step.name, - step_index: stepIndex, - action: 'approve', - actor_id: input.actorId, - comment: input.comment ?? null, - created_at: now, - }, { context: SYSTEM_CTX }); - - // Unanimous: only advance once every original approver has approved at this step_index. - if (step.behavior === 'unanimous') { - const original = await this.expandApprovers(step, req.payload, (req as any).organization_id ?? null); - const acts = await this.engine.find('sys_approval_action', { - where: { request_id: req.id, step_index: stepIndex, action: 'approve' }, - limit: 500, context: SYSTEM_CTX, - }); - const approved = new Set((acts ?? []).map((a: any) => String(a.actor_id ?? '')).filter(Boolean)); - const stillPending = original.filter(a => !approved.has(a)); - if (stillPending.length > 0) { - // Update pending_approvers to those who haven't voted yet. - await this.engine.update('sys_approval_request', { - id: req.id, - pending_approvers: stillPending.join(','), - updated_at: now, - }, { context: SYSTEM_CTX }); - const fresh = await this.getRequest(req.id, context); - return { request: fresh!, finalized: false }; - } - } - - // Advance the request — either to next step or to finalized=approved. - if (stepIndex + 1 >= steps.length) { - await this.engine.update('sys_approval_request', { - id: req.id, - status: 'approved', - pending_approvers: null, - completed_at: now, - updated_at: now, - }, { context: SYSTEM_CTX }); - const fresh = await this.getRequest(req.id, context); - // Phase B: step.onApprove + process.onFinalApprove + status mirror. - await this.runActions((step as any)?.onApprove, 'step_approve', process, fresh!, step, input.actorId, input.comment); - await this.syncStatusField(process, fresh!); - await this.runActions((process.definition as any)?.onFinalApprove, 'final_approve', process, fresh!, step, input.actorId, input.comment); - return { request: fresh!, finalized: true }; - } - - const nextStep = steps[stepIndex + 1]; - const nextApprovers = await this.expandApprovers(nextStep, req.payload, (req as any).organization_id ?? null); - await this.engine.update('sys_approval_request', { - id: req.id, - current_step: nextStep.name, - current_step_index: stepIndex + 1, - pending_approvers: nextApprovers.join(','), - updated_at: now, - }, { context: SYSTEM_CTX }); - const fresh = await this.getRequest(req.id, context); - // Phase B: step.onApprove fires when transitioning out of this step. - await this.runActions((step as any)?.onApprove, 'step_approve', process, fresh!, step, input.actorId, input.comment); - return { request: fresh!, finalized: false }; - } - - async reject(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise { - const req = await this.getRequest(requestId, context); - if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`); - if (req.status !== 'pending') throw new Error(`INVALID_STATE: request is ${req.status}`); - if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required'); - if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) { - throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`); - } - - const process = await this.loadProcessForRequest(req, context); - if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`); - const steps: any[] = process.definition?.steps ?? []; - const stepIndex = req.current_step_index ?? 0; - const step = steps[stepIndex]; - - const now = this.clock.now().toISOString(); - await this.engine.insert('sys_approval_action', { - id: uid('aact'), - request_id: req.id, - organization_id: (req as any).organization_id ?? null, - step_name: step?.name, - step_index: stepIndex, - action: 'reject', - actor_id: input.actorId, - comment: input.comment ?? null, - created_at: now, - }, { context: SYSTEM_CTX }); - - if (step?.rejectionBehavior === 'back_to_previous' && stepIndex > 0) { - const prev = steps[stepIndex - 1]; - const prevApprovers = await this.expandApprovers(prev, req.payload, (req as any).organization_id ?? null); - await this.engine.update('sys_approval_request', { - id: req.id, - current_step: prev.name, - current_step_index: stepIndex - 1, - pending_approvers: prevApprovers.join(','), - updated_at: now, - }, { context: SYSTEM_CTX }); - const fresh = await this.getRequest(req.id, context); - // Phase B: step-level onReject fires on non-final rejection too. - await this.runActions((step as any)?.onReject, 'step_reject', process, fresh!, step, input.actorId, input.comment); - return { request: fresh!, finalized: false }; - } - - await this.engine.update('sys_approval_request', { - id: req.id, - status: 'rejected', - pending_approvers: null, - completed_at: now, - updated_at: now, - }, { context: SYSTEM_CTX }); - const fresh = await this.getRequest(req.id, context); - // Phase B: step.onReject + process.onFinalReject + status mirror. - await this.runActions((step as any)?.onReject, 'step_reject', process, fresh!, step, input.actorId, input.comment); - await this.syncStatusField(process, fresh!); - await this.runActions((process.definition as any)?.onFinalReject, 'final_reject', process, fresh!, step, input.actorId, input.comment); - return { request: fresh!, finalized: true }; - } - - async recall(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise { - const req = await this.getRequest(requestId, context); - if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`); - if (req.status !== 'pending') throw new Error(`INVALID_STATE: request is ${req.status}`); - if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required'); - if (!context.isSystem && req.submitter_id && req.submitter_id !== input.actorId) { - throw new Error(`FORBIDDEN: only the submitter can recall this request`); - } - - const now = this.clock.now().toISOString(); - await this.engine.insert('sys_approval_action', { - id: uid('aact'), - request_id: req.id, - organization_id: (req as any).organization_id ?? null, - step_name: req.current_step, - step_index: req.current_step_index, - action: 'recall', - actor_id: input.actorId, - comment: input.comment ?? null, - created_at: now, - }, { context: SYSTEM_CTX }); - - await this.engine.update('sys_approval_request', { - id: req.id, - status: 'recalled', - pending_approvers: null, - completed_at: now, - updated_at: now, - }, { context: SYSTEM_CTX }); - const fresh = await this.getRequest(req.id, context); - // Phase B: process.onRecall + status mirror. - const process = await this.loadProcessForRequest(req, context); - if (process) { - await this.syncStatusField(process, fresh!); - await this.runActions((process.definition as any)?.onRecall, 'recall', process, fresh!, undefined, input.actorId, input.comment); + this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`); } - return { request: fresh!, finalized: true }; } // ── ADR-0019: Approval-as-flow-node ────────────────────────── // // A flow's Approval node opens a request via `openNodeRequest` (carrying its // own approvers/behavior config and the suspended run id), then suspends. A - // later `decideNode` finalizes it; the node provider resumes the flow run - // down the matching `approve`/`reject` edge. These reuse the same approver - // expansion, audit rows, lock (the beforeUpdate hook keys on a *pending* - // request, so finalizing auto-releases it), and status mirror as the - // process-driven path — only the trigger and continuation differ. - - /** Mirror a request status onto a business-object field, if configured. */ - private async mirrorStatusField(object: string, recordId: string, field: string, status: string): Promise { - try { - await this.engine.update(object, { id: recordId, [field]: status }, { context: SYSTEM_CTX }); - } catch (err: any) { - this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`); - } - } + // later `decide` finalizes it and resumes the flow run down the matching + // `approve`/`reject` edge. The record lock is enforced by a beforeUpdate hook + // keyed on a *pending* request, so finalizing auto-releases it. /** - * Open a pending approval request on behalf of a flow's Approval node - * (ADR-0019). Self-contained: the node config (approvers/behavior/status - * field) is snapshotted on the row, since a node-driven request has no - * `sys_approval_process` to resolve against. + * Open a pending approval request on behalf of a flow's Approval node. The + * node config (approvers / behavior / status field) is snapshotted on the row + * so a decision can be made without any process to resolve against. */ async openNodeRequest( input: { @@ -841,7 +301,7 @@ export class ApprovalService implements IApprovalService { if (!input.recordId) throw new Error('VALIDATION_FAILED: recordId is required'); if (!input.runId) throw new Error('VALIDATION_FAILED: runId is required'); - // One pending request per (object, record) — same guard as submit(). + // One pending request per (object, record). const existing = await this.engine.find('sys_approval_request', { where: { object_name: input.object, record_id: input.recordId, status: 'pending' }, limit: 1, context: SYSTEM_CTX, @@ -881,10 +341,8 @@ export class ApprovalService implements IApprovalService { actor_id: input.submitterId ?? context.userId ?? null, comment: null, created_at: now, }, { context: SYSTEM_CTX }); - if (input.config.lockRecord !== false) { - // Lock is enforced by the existing beforeUpdate hook keyed on a pending - // request; no extra write needed here. - } + // Record lock (when `lockRecord !== false`) is enforced by the beforeUpdate + // hook keyed on the now-pending request; no extra write needed here. if (input.config.approvalStatusField) { await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, 'pending'); } @@ -893,10 +351,11 @@ export class ApprovalService implements IApprovalService { } /** - * Record a decision on a node-driven request (ADR-0019). Honours the node's - * `unanimous` behavior (holds until every approver has approved). When the - * request finalizes, returns the suspended run id + node id so the node - * provider can resume the flow down the matching branch. + * Record a decision on a node-driven request. Honours the node's `unanimous` + * behavior (holds until every approver has approved). When the request + * finalizes, returns the suspended run id + node id so the caller (or + * {@link ApprovalService.decide}) can resume the flow down the matching + * branch. */ async decideNode( requestId: string, @@ -965,6 +424,97 @@ export class ApprovalService implements IApprovalService { return { request: fresh!, runId, nodeId, finalized: true, decision: input.decision }; } + /** + * Public contract entrypoint (ADR-0019). Records a decision on a node-driven + * request via {@link ApprovalService.decideNode} and, when it finalizes, + * resumes the owning flow run down the matching `approve` / `reject` edge. + */ + async decide( + requestId: string, + input: ApprovalDecisionInput, + context: SharingExecutionContext, + ): Promise { + const result = await this.decideNode(requestId, input, context); + + let resumed = false; + if (result.finalized && result.runId && typeof this.automation?.resume === 'function') { + const branchLabel = result.decision === 'approve' + ? APPROVAL_BRANCH_LABELS.approve + : APPROVAL_BRANCH_LABELS.reject; + try { + await this.automation.resume(result.runId, { + branchLabel, + output: { decision: result.decision, requestId }, + }); + resumed = true; + } catch (err: any) { + this.logger?.warn?.('[approvals] resume after decision failed', { + request: requestId, run: result.runId, error: err?.message ?? String(err), + }); + } + } + + return { + request: result.request, + finalized: result.finalized, + decision: result.decision, + runId: result.runId, + resumed, + }; + } + + // ── Read API ───────────────────────────────────────────────── + + async listRequests( + filter: { + object?: string; + recordId?: string; + status?: ApprovalStatus | ApprovalStatus[]; + approverId?: string; + submitterId?: string; + } | undefined, + context: SharingExecutionContext, + ): Promise { + const f: any = {}; + if (filter?.object) f.object_name = filter.object; + if (filter?.recordId) f.record_id = filter.recordId; + if (filter?.submitterId) f.submitter_id = filter.submitterId; + // Tenant isolation: when a caller context carries a tenant identifier + // (organizationId / tenantId), scope the query to that tenant. SYSTEM + // callers (no tenant) see all rows. This prevents the bespoke endpoint + // from leaking other-tenant rows since we deliberately query with + // SYSTEM_CTX to bypass RLS on the engine (we need CSV substring match + // on pending_approvers which RLS can't model cleanly). + const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId; + if (tenantOrg) f.organization_id = tenantOrg; + // Status: when array, post-filter; when single, push into engine filter. + let statusFilter: ApprovalStatus[] | undefined; + if (Array.isArray(filter?.status)) statusFilter = filter!.status as ApprovalStatus[]; + else if (filter?.status) f.status = filter.status; + + const rows = await this.engine.find('sys_approval_request', { + where: f, limit: 500, orderBy: [{ field: 'updated_at', direction: 'desc' }], context: SYSTEM_CTX, + }); + let list = Array.isArray(rows) ? rows.map(rowFromRequest) : []; + if (statusFilter) list = list.filter(r => statusFilter!.includes(r.status)); + if (filter?.approverId) { + const target = filter.approverId; + list = list.filter(r => (r.pending_approvers ?? []).includes(target)); + } + return list; + } + + async getRequest(requestId: string, context: SharingExecutionContext): Promise { + if (!requestId) return null; + const where: any = { id: requestId }; + const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId; + if (tenantOrg) where.organization_id = tenantOrg; + const rows = await this.engine.find('sys_approval_request', { + where, limit: 1, context: SYSTEM_CTX, + }); + return Array.isArray(rows) && rows[0] ? rowFromRequest(rows[0]) : null; + } + async listActions(requestId: string, context: SharingExecutionContext): Promise { if (!requestId) return []; // Tenant gate: ensure the caller can see the parent request before diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index f9d46d2918..8027612a72 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -2,30 +2,32 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { - SysApprovalProcess, SysApprovalRequest, SysApprovalAction, } from '@objectstack/platform-objects/audit'; import { ApprovalService, type ApprovalEngine } from './approval-service.js'; -import { bindProcessHooks, unbindAllHooks } from './lifecycle-hooks.js'; +import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js'; import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js'; export interface ApprovalsPluginOptions { /** Disable runtime registration (schemas still register). */ disableService?: boolean; /** - * Disable Phase B auto-trigger / lock hooks. Schema definition stays - * intact; only the engine-level wiring is suppressed. Useful when a - * caller wants the manual API only (e.g. tests). + * Disable the record-lock hook. Schema + service stay intact; only the + * engine-level lock wiring is suppressed. Useful when a caller wants the + * manual API only (e.g. tests). */ disableAutoHooks?: boolean; } /** - * ApprovalsServicePlugin — registers sys_approval_{process,request,action}, - * the `approvals` service, and Phase B lifecycle hooks (auto-trigger, - * record lock, status mirror). SLA escalation dispatcher is a later - * milestone. + * ApprovalsServicePlugin — registers sys_approval_{request,action}, the + * `approvals` service, the `approval` flow node executor (ADR-0019), and the + * record-lock hook. + * + * ADR-0019: approval is no longer a standalone process engine. A flow's + * Approval node opens a request and suspends the run; a decision via the + * service resumes it down the matching branch. */ export class ApprovalsServicePlugin implements Plugin { name = 'com.objectstack.service.approvals'; @@ -36,7 +38,6 @@ export class ApprovalsServicePlugin implements Plugin { private readonly options: ApprovalsPluginOptions; private service?: ApprovalService; private engine?: any; - private logger?: any; constructor(options: ApprovalsPluginOptions = {}) { this.options = options; @@ -51,7 +52,7 @@ export class ApprovalsServicePlugin implements Plugin { scope: 'system', defaultDatasource: 'cloud', namespace: 'sys', - objects: [SysApprovalProcess, SysApprovalRequest, SysApprovalAction], + objects: [SysApprovalRequest, SysApprovalAction], }); ctx.logger.info('ApprovalsServicePlugin: schemas registered'); } @@ -66,42 +67,19 @@ export class ApprovalsServicePlugin implements Plugin { return; } this.engine = engine; - this.logger = ctx.logger; - - // ADR-0009: try to wire the metadata repository for execution pinning. - // The approvals service degrades to the projection-table path if no - // metadata service is registered (e.g. in tests or minimal setups). - let metadataRepo: any; - try { - const meta = ctx.getService('metadata'); - metadataRepo = meta?.getRepository?.(); - } catch { /* metadata plugin not loaded — fall back */ } this.service = new ApprovalService({ engine: engine as ApprovalEngine, logger: ctx.logger, - metadataRepo, }); - if (metadataRepo) { - ctx.logger.info('ApprovalsServicePlugin: execution pinning enabled (ADR-0009)'); - } - + // Record lock: block edits to a record while it has a pending request. if (!this.options.disableAutoHooks) { - // Re-bind hooks on every registry mutation. - this.service.setRegistryChangeHandler(() => this.rebindHooks()); - // Initial bind happens once the kernel is ready so the AppPlugin's - // declarative process seeder has already populated sys_approval_process. - const hookOn = (ctx as any).hook ?? (ctx as any).on; - if (typeof hookOn === 'function') { - try { - hookOn.call(ctx, 'kernel:ready', async () => { await this.rebindHooks(); }); - } catch { - // Fall through to immediate bind (no kernel:ready event). - await this.rebindHooks(); - } - } else { - await this.rebindHooks(); + try { + unbindAllHooks(engine); + bindApprovalLockHook(engine, ctx.logger); + } catch (err: any) { + ctx.logger.warn?.('[approvals] failed to bind record-lock hook', { error: err?.message }); } } @@ -109,11 +87,13 @@ export class ApprovalsServicePlugin implements Plugin { ctx.logger.info('ApprovalsServicePlugin: service registered'); // ADR-0019: contribute the `approval` node to the flow engine when one is - // present. Optional — the manual approval API works without it; this is the - // bridge that lets a flow suspend on an Approval node and resume on decision. + // present. The node lets a flow suspend on an approval and resume on + // decision; the service is wired to the same engine so `decide()` can + // resume the suspended run. try { const automation = ctx.getService('automation'); if (automation && typeof automation.registerNodeExecutor === 'function') { + this.service.attachAutomation(automation); registerApprovalNode(automation, this.service, ctx.logger); } } catch { @@ -121,21 +101,9 @@ export class ApprovalsServicePlugin implements Plugin { } } - private async rebindHooks(): Promise { - if (!this.engine || !this.service) return; - try { - unbindAllHooks(this.engine); - const processes = await this.service.listProcesses({ activeOnly: true }, { isSystem: true, roles: [], permissions: [] } as any); - bindProcessHooks(this.engine, this.service, processes, this.logger); - } catch (err: any) { - this.logger?.warn?.('[approvals] rebindHooks failed', { error: err?.message }); - } - } - async stop(_ctx: PluginContext): Promise { if (this.engine) { try { unbindAllHooks(this.engine); } catch { /* ignore */ } } } } - diff --git a/packages/plugins/plugin-approvals/src/index.ts b/packages/plugins/plugin-approvals/src/index.ts index d40cac652d..8bccde322b 100644 --- a/packages/plugins/plugin-approvals/src/index.ts +++ b/packages/plugins/plugin-approvals/src/index.ts @@ -3,13 +3,14 @@ /** * @objectstack/plugin-approvals * - * Multi-step approval engine for ObjectStack. - * Persists sys_approval_process / sys_approval_request / sys_approval_action - * and drives the cycle: submit → review → approve/reject → effects. + * Approval-as-flow-node runtime (ADR-0019). Persists sys_approval_request / + * sys_approval_action, resolves approvers, enforces the record lock, and + * records decisions that resume the owning flow run. Approval orchestration + * (when to pause, which branch to take) lives on the one automation engine via + * the `approval` node. */ export { - SysApprovalProcess, SysApprovalRequest, SysApprovalAction, } from '@objectstack/platform-objects/audit'; @@ -18,6 +19,7 @@ export { type ApprovalEngine, type ApprovalClock, type ApprovalServiceOptions, + type ApprovalResumeSurface, } from './approval-service.js'; export { ApprovalsServicePlugin, @@ -25,17 +27,13 @@ export { } from './approvals-plugin.js'; export { registerApprovalNode, - decideApprovalNode, type ApprovalAutomationSurface, } from './approval-node.js'; export type { IApprovalService, - ApprovalProcessRow, ApprovalRequestRow, ApprovalActionRow, ApprovalDecisionInput, ApprovalDecisionResult, ApprovalStatus, - DefineApprovalProcessInput, - SubmitApprovalInput, } from '@objectstack/spec/contracts'; diff --git a/packages/plugins/plugin-approvals/src/lifecycle-hooks.ts b/packages/plugins/plugin-approvals/src/lifecycle-hooks.ts index 6af7fccab7..2e315229ae 100644 --- a/packages/plugins/plugin-approvals/src/lifecycle-hooks.ts +++ b/packages/plugins/plugin-approvals/src/lifecycle-hooks.ts @@ -1,32 +1,29 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. /** - * Lifecycle Hooks — Phase B auto-takeover. + * Lifecycle Hooks — node-era record lock (ADR-0019). * - * For each active ApprovalProcess we bind three hooks on its target object: + * Approval is now a flow node, so there is no per-object process registry to + * bind auto-trigger hooks against — a flow decides *when* to open an approval. + * What remains worth enforcing at the data layer is the **record lock**: while + * a record has a pending `sys_approval_request`, block edits to it. * - * 1. `afterInsert` — evaluate `entryCriteria` against the new record; - * if truthy and no pending request exists, auto-submit one. - * 2. `afterUpdate` — same as above but for updates that newly satisfy - * criteria (e.g. amount edited above threshold). - * 3. `beforeUpdate` — when `lockRecord=true`, block edits to a record - * that has a pending request, EXCEPT when the only fields being - * changed are the configured `approvalStatusField` (so the engine's - * own status mirror is not blocked). + * A single global `beforeUpdate` hook handles every object (the target object + * of an approval node is only known at flow-run time). For each update it: * - * All hooks are registered with `packageId: 'plugin-approvals:auto'` so - * that re-bind on `defineProcess`/`deleteProcess` can call - * `engine.unregisterHooksByPackage(...)` first. + * 1. Skips engine self-writes (status mirror) and `sys_approval_*` bookkeeping. + * 2. Looks up a pending request for `(object, recordId)`. + * 3. Reads the lock policy from that request's `node_config_json` snapshot: + * - `lockRecord === false` → allow. + * - otherwise block, EXCEPT when the only changed field is the configured + * `approvalStatusField` (so the status mirror is never blocked) or the + * caller is an `admin`. + * + * Registered under `packageId: 'plugin-approvals:lock'` so it can be cleanly + * unbound on plugin stop. */ -import { ExpressionEngine } from '@objectstack/formula'; -import type { Expression } from '@objectstack/spec'; -import type { ApprovalProcessRow } from '@objectstack/spec/contracts'; -import type { ApprovalService } from './approval-service.js'; - -export const APPROVALS_HOOK_PACKAGE = 'plugin-approvals:auto'; - -const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; +export const APPROVALS_HOOK_PACKAGE = 'plugin-approvals:lock'; interface MinimalEngine { registerHook(event: string, handler: (ctx: any) => any | Promise, options?: { @@ -45,206 +42,74 @@ interface MinimalLogger { error?: (msg: any, ...rest: any[]) => void; } -/** - * Evaluate an entry criteria expression against a record. Returns `true` - * when no criteria is set (matches everything). Returns `false` on - * evaluation failure (fail-closed — better to skip than auto-submit on a - * broken expression). - */ -function evaluateCriteria(criteria: unknown, record: Record, logger?: MinimalLogger): boolean { - if (criteria == null || criteria === '' ) return true; - let expr: Expression; - if (typeof criteria === 'string') { - expr = { dialect: 'cel', source: criteria }; - } else if (typeof criteria === 'object' && (criteria as any).dialect) { - expr = criteria as Expression; - } else { - return true; +function parseJson(raw: unknown, fallback: T): T { + if (raw == null || raw === '') return fallback; + if (typeof raw === 'string') { + try { return JSON.parse(raw) as T; } catch { return fallback; } } - if (!expr.source || !expr.source.trim()) return true; - const r = ExpressionEngine.evaluate(expr, { record }); - if (!r.ok) { - logger?.warn?.('[approvals] entryCriteria evaluation failed; skipping auto-submit', { - source: expr.source, - error: r.error.message, - }); - return false; - } - return Boolean(r.value); + return raw as T; } -/** Does this record already have a pending approval request? */ -async function hasPendingRequest( +/** The pending request gating a record, plus its snapshotted node config. */ +async function pendingRequestFor( engine: MinimalEngine, objectName: string, recordId: string, -): Promise { +): Promise { try { const rows = await engine.find('sys_approval_request', { where: { object_name: objectName, record_id: String(recordId), status: 'pending' }, limit: 1, } as any); - return Array.isArray(rows) && rows.length > 0; + return Array.isArray(rows) && rows[0] ? rows[0] : null; } catch { - return false; + return null; } } /** - * Bind auto-trigger + lock hooks for the supplied active processes. - * Caller is responsible for calling `unbindAll` first if re-binding. + * Bind the global record-lock hook. Caller is responsible for calling + * {@link unbindAllHooks} first if re-binding. */ -export function bindProcessHooks( - engine: MinimalEngine, - service: ApprovalService, - processes: ApprovalProcessRow[], - logger?: MinimalLogger, -): void { - // Group processes by object so we can register one hook per object - // and fan out internally — keeps the engine's hook map compact. - const byObject = new Map(); - for (const p of processes) { - if (!(p as any).active && !(p as any).is_active) continue; - if (!p.object_name) continue; - const list = byObject.get(p.object_name) ?? []; - list.push(p); - byObject.set(p.object_name, list); - } - - for (const [objectName, procs] of byObject.entries()) { - // ---- auto-trigger (afterInsert) ---- - engine.registerHook('afterInsert', async (ctx: any) => { - try { - const record = (ctx?.result ?? ctx?.input?.data ?? {}) as Record; - const id = String((record as any)?.id ?? ''); - if (!id) return; - for (const proc of procs) { - await tryAutoSubmit(engine, service, proc, objectName, id, record, ctx, logger); - } - } catch (err: any) { - logger?.warn?.('[approvals] afterInsert auto-trigger failed', { error: err?.message }); - } - }, { object: objectName, packageId: APPROVALS_HOOK_PACKAGE, priority: 200 }); - - // ---- auto-trigger (afterUpdate) ---- - engine.registerHook('afterUpdate', async (ctx: any) => { - // Ignore engine self-writes (status mirror, field_update from - // post-actions, etc) — otherwise post-finalize updates would loop - // a fresh approval on every state change. - if ((ctx?.session as any)?.isSystem) return; - try { - const result = (ctx?.result ?? {}) as Record; - const id = String((ctx?.input?.id ?? (result as any)?.id ?? '') as string); - if (!id) return; - // result may be { affected: 1 } for some drivers; merge previous+input.data as the - // best-effort record snapshot for criteria evaluation. - const record: Record = { - ...(ctx?.previous ?? {}), - ...((result as any)?.id ? result : {}), - ...((ctx?.input?.data ?? {}) as Record), - id, - }; - for (const proc of procs) { - await tryAutoSubmit(engine, service, proc, objectName, id, record, ctx, logger); - } - } catch (err: any) { - logger?.warn?.('[approvals] afterUpdate auto-trigger failed', { error: err?.message }); - } - }, { object: objectName, packageId: APPROVALS_HOOK_PACKAGE, priority: 200 }); - - // ---- record lock (beforeUpdate) ---- - const lockProcs = procs.filter((p) => (p.definition as any)?.lockRecord !== false); - if (lockProcs.length === 0) continue; - engine.registerHook('beforeUpdate', async (ctx: any) => { - const id = String((ctx?.input?.id ?? '') as string); - if (!id) return; - const data = (ctx?.input?.data ?? {}) as Record; - const changedFields = Object.keys(data).filter((k) => k !== 'id' && k !== 'updated_at'); - if (changedFields.length === 0) return; - - // Allow engine self-writes (status mirror, field_update from actions, etc). - if ((ctx?.session as any)?.isSystem) return; - - // Allow when every changed field is an approval status mirror. - const mirrorFields = new Set(); - for (const p of lockProcs) { - const f = (p.definition as any)?.approvalStatusField; - if (typeof f === 'string' && f) mirrorFields.add(f); - } - const onlyMirror = changedFields.every((f) => mirrorFields.has(f)); - if (onlyMirror) return; - - // Allow admin override: roles include 'admin'. - const roles = (ctx?.session?.roles ?? []) as string[]; - if (Array.isArray(roles) && roles.includes('admin')) return; - - const pending = await hasPendingRequest(engine, objectName, id); - if (!pending) return; - - const err: any = new Error('RECORD_LOCKED: record is locked while an approval is in progress'); - err.code = 'RECORD_LOCKED'; - err.statusCode = 409; - throw err; - }, { object: objectName, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 }); - } - - logger?.info?.('[approvals] lifecycle hooks bound', { - objects: Array.from(byObject.keys()), - processCount: processes.length, - }); +export function bindApprovalLockHook(engine: MinimalEngine, logger?: MinimalLogger): void { + engine.registerHook('beforeUpdate', async (ctx: any) => { + const id = String((ctx?.input?.id ?? '') as string); + if (!id) return; + const object = (ctx?.object ?? ctx?.objectName) as string | undefined; + // No object name (shouldn't happen) or our own bookkeeping objects → skip. + if (!object || String(object).startsWith('sys_approval')) return; + + const data = (ctx?.input?.data ?? {}) as Record; + const changedFields = Object.keys(data).filter((k) => k !== 'id' && k !== 'updated_at'); + if (changedFields.length === 0) return; + + // Allow engine self-writes (status mirror from the approvals service, etc). + if ((ctx?.session as any)?.isSystem) return; + + // Allow admin override. + const roles = (ctx?.session?.roles ?? []) as string[]; + if (Array.isArray(roles) && roles.includes('admin')) return; + + const pending = await pendingRequestFor(engine, object, id); + if (!pending) return; + + const config = parseJson(pending.node_config_json, {}); + if (config?.lockRecord === false) return; + + // Allow when every changed field is the approval status mirror. + const mirror = config?.approvalStatusField; + if (typeof mirror === 'string' && mirror && changedFields.every((f) => f === mirror)) return; + + const err: any = new Error('RECORD_LOCKED: record is locked while an approval is in progress'); + err.code = 'RECORD_LOCKED'; + err.statusCode = 409; + throw err; + }, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 }); + + logger?.info?.('[approvals] record-lock hook bound'); } -/** Unregister every hook the auto-trigger module ever registered. */ +/** Unregister every hook the lock module registered. */ export function unbindAllHooks(engine: MinimalEngine): number { return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE); } - -async function tryAutoSubmit( - engine: MinimalEngine, - service: ApprovalService, - process: ApprovalProcessRow, - objectName: string, - recordId: string, - record: Record, - ctx: any, - logger?: MinimalLogger, -): Promise { - try { - const criteria = (process.definition as any)?.entryCriteria; - const passes = evaluateCriteria(criteria, record, logger); - if (!passes) return; - if (await hasPendingRequest(engine, objectName, recordId)) return; - // Guard: if the record's mirror status field is already a terminal - // state (approved / rejected / recalled), do NOT auto-submit again — - // otherwise every post-finalize edit would loop a fresh approval. - const statusField = (process.definition as any)?.approvalStatusField; - if (statusField) { - const current = (record as any)?.[statusField]; - if (current === 'approved' || current === 'rejected' || current === 'recalled') return; - } - - const submitterId = (ctx?.session?.userId ?? null) as string | null; - const submitterOrg = (ctx?.session?.tenantId ?? ctx?.session?.organizationId ?? null) as string | null; - await service.submit({ - object: objectName, - recordId, - processName: process.name, - payload: record, - submitterId, - }, { ...SYSTEM_CTX, userId: submitterId ?? undefined, organizationId: submitterOrg ?? undefined, tenantId: submitterOrg ?? undefined } as any); - - logger?.info?.('[approvals] auto-submitted approval', { - process: process.name, - object: objectName, - record: recordId, - }); - } catch (err: any) { - if (err?.code === 'DUPLICATE_REQUEST') return; - logger?.warn?.('[approvals] auto-submit failed', { - process: process.name, - object: objectName, - record: recordId, - error: err?.message ?? String(err), - }); - } -} diff --git a/packages/plugins/plugin-approvals/src/phase-b.test.ts b/packages/plugins/plugin-approvals/src/phase-b.test.ts deleted file mode 100644 index 0775846e94..0000000000 --- a/packages/plugins/plugin-approvals/src/phase-b.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Phase B integration tests. - * - * - status mirror (`approvalStatusField` on the business record) - * - process-level `onSubmit / onFinalApprove / onFinalReject / onRecall` - * - step-level `onApprove / onReject` - * - `inbox_notify` action writes `sys_notification` rows - * - `field_update` action writes the business record (token interpolation) - * - lifecycle hooks: afterInsert auto-submit, lock on beforeUpdate, allow - * status-mirror writes through, allow admin override. - */ - -import { describe, it, expect, beforeEach } from 'vitest'; -import { ApprovalService } from './approval-service.js'; -import { bindProcessHooks, unbindAllHooks } from './lifecycle-hooks.js'; - -interface FakeRow { [k: string]: any } - -function makeFakeEngine() { - const tables: Record = {}; - const ensure = (n: string) => (tables[n] ??= []); - const hooks: Record any | Promise; object?: string | string[]; packageId?: string }>> = {}; - - function matches(row: FakeRow, filter: any): boolean { - if (!filter || typeof filter !== 'object') return true; - for (const [k, v] of Object.entries(filter)) { - if (row[k] !== v) return false; - } - return true; - } - - async function fire(event: string, ctx: any) { - const list = hooks[event] ?? []; - for (const h of list) { - if (h.object) { - const objs = Array.isArray(h.object) ? h.object : [h.object]; - if (!objs.includes(ctx.object)) continue; - } - await h.handler(ctx); - } - } - - return { - _tables: tables, - _hooks: hooks, - async find(object: string, options?: any, _opts?: any) { - const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where)); - return rows.slice(0, options?.limit ?? 1000); - }, - async insert(object: string, data: any, opts?: any) { - const row = { ...data }; - ensure(object).push(row); - const ctx = { object, event: 'afterInsert', result: row, input: { data: row }, session: opts?.context ?? {} }; - await fire('afterInsert', ctx); - return row; - }, - async update(object: string, idOrData: any, opts?: any) { - const data = typeof idOrData === 'object' ? idOrData : opts; - const id = typeof idOrData === 'object' ? idOrData.id : idOrData; - // beforeUpdate (skip status-mirror writes from system context — the - // hook itself decides via the `data.keys ⊆ approvalStatusField` rule). - const beforeCtx = { object, event: 'beforeUpdate', input: { id, data }, session: opts?.context ?? {} }; - await fire('beforeUpdate', beforeCtx); - const table = ensure(object); - const i = table.findIndex(r => r.id === id); - if (i >= 0) table[i] = { ...table[i], ...data }; - const after = table[i]; - const afterCtx = { object, event: 'afterUpdate', input: { id, data }, result: after, session: opts?.context ?? {} }; - await fire('afterUpdate', afterCtx); - return after; - }, - async delete(object: string, options?: any) { - const table = ensure(object); - const id = options?.where?.id ?? options?.id; - const i = table.findIndex(r => r.id === id); - if (i >= 0) table.splice(i, 1); - return { id }; - }, - registerHook(event: string, handler: any, options?: any) { - (hooks[event] ??= []).push({ handler, object: options?.object, packageId: options?.packageId }); - }, - unregisterHooksByPackage(packageId: string) { - let removed = 0; - for (const ev of Object.keys(hooks)) { - const before = hooks[ev].length; - hooks[ev] = hooks[ev].filter(h => h.packageId !== packageId); - removed += before - hooks[ev].length; - } - return removed; - }, - }; -} - -const SYS = { isSystem: true, roles: [], permissions: [] }; -const USR = { userId: 'submitter', roles: [], permissions: [] }; - -function processWithMirror() { - return { - name: 'discount_approval', - label: 'Discount Approval', - object: 'opportunity', - active: true, - approvalStatusField: 'approval_status', - lockRecord: true, - entryCriteria: 'record.amount > 50000', - onSubmit: [ - { - type: 'inbox_notify' as const, - name: 'notify_pending', - config: { - to: 'pending_approvers', - title: 'Discount needs approval', - body: 'Opportunity {record_id} amount review', - }, - }, - ], - onFinalApprove: [ - { type: 'field_update' as const, name: 'close_won', config: { field: 'stage', value: 'closed_won' } }, - ], - onFinalReject: [ - { type: 'inbox_notify' as const, name: 'tell_submitter', config: { to: 'submitter', title: 'Rejected', body: 'Sorry' } }, - ], - onRecall: [ - { type: 'inbox_notify' as const, name: 'recalled', config: { to: 'submitter', title: 'Recalled', body: 'Pulled back' } }, - ], - steps: [ - { - name: 'sales_manager', - label: 'Sales Manager', - approvers: [{ type: 'user' as const, value: 'manager' }], - behavior: 'first_response' as const, - }, - ], - }; -} - -describe('Phase B — approval auto-takeover', () => { - let engine: ReturnType; - let svc: ApprovalService; - - beforeEach(() => { - engine = makeFakeEngine(); - svc = new ApprovalService({ engine: engine as any }); - }); - - describe('status mirror + actions', () => { - beforeEach(async () => { - // Seed a business record so syncStatusField can update it. - engine._tables.opportunity = [{ id: 'opp1', amount: 80000, stage: 'qualification', approval_status: 'not_submitted' }]; - await svc.defineProcess({ - name: 'discount_approval', - label: 'Discount Approval', - object: 'opportunity', - definition: processWithMirror(), - }, SYS as any); - }); - - it('writes onSubmit notifications and mirrors status to the business record', async () => { - await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'submitter', payload: engine._tables.opportunity[0] }, USR as any); - - // status mirrored. - const opp = engine._tables.opportunity[0]; - expect(opp.approval_status).toBe('pending'); - - // inbox notification written to pending approvers. - const notes = engine._tables.sys_notification ?? []; - expect(notes.length).toBeGreaterThanOrEqual(1); - expect(notes.some(n => n.recipient_id === 'manager' && /Opportunity opp1/.test(n.body))).toBe(true); - }); - - it('runs onFinalApprove field_update on finalize, mirrors status=approved', async () => { - const submitted = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'submitter', payload: engine._tables.opportunity[0] }, USR as any); - await svc.approve(submitted.id, { actorId: 'manager' }, SYS as any); - - const opp = engine._tables.opportunity[0]; - expect(opp.stage).toBe('closed_won'); - expect(opp.approval_status).toBe('approved'); - }); - - it('runs onFinalReject inbox_notify on rejection, mirrors status=rejected', async () => { - const submitted = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'submitter', payload: engine._tables.opportunity[0] }, USR as any); - await svc.reject(submitted.id, { actorId: 'manager', comment: 'too low' }, SYS as any); - - const opp = engine._tables.opportunity[0]; - expect(opp.approval_status).toBe('rejected'); - const notes = engine._tables.sys_notification ?? []; - expect(notes.some(n => n.recipient_id === 'submitter' && n.title === 'Rejected')).toBe(true); - }); - - it('runs onRecall and mirrors status=recalled', async () => { - const submitted = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'submitter', payload: engine._tables.opportunity[0] }, USR as any); - await svc.recall(submitted.id, { actorId: 'submitter' }, USR as any); - - const opp = engine._tables.opportunity[0]; - expect(opp.approval_status).toBe('recalled'); - const notes = engine._tables.sys_notification ?? []; - expect(notes.some(n => n.title === 'Recalled')).toBe(true); - }); - }); - - describe('lifecycle hooks', () => { - beforeEach(async () => { - await svc.defineProcess({ - name: 'discount_approval', - label: 'Discount Approval', - object: 'opportunity', - definition: processWithMirror(), - }, SYS as any); - const procs = await svc.listProcesses({ activeOnly: true }, SYS as any); - bindProcessHooks(engine as any, svc, procs); - }); - - it('auto-submits a request when an inserted record matches entryCriteria', async () => { - await engine.insert('opportunity', { id: 'opp_high', amount: 100000, stage: 'qualification' }); - // Drain microtasks (insert kicks off the hook). - await new Promise(r => setTimeout(r, 0)); - const requests = engine._tables.sys_approval_request ?? []; - expect(requests.length).toBe(1); - expect(requests[0].object_name).toBe('opportunity'); - expect(requests[0].record_id).toBe('opp_high'); - }); - - it('does NOT auto-submit when entryCriteria evaluates to false', async () => { - await engine.insert('opportunity', { id: 'opp_low', amount: 1000, stage: 'qualification' }); - await new Promise(r => setTimeout(r, 0)); - expect(engine._tables.sys_approval_request ?? []).toHaveLength(0); - }); - - it('does NOT double-submit when criteria continues to be true on update', async () => { - await engine.insert('opportunity', { id: 'opp_dup', amount: 100000, stage: 'qualification' }); - await new Promise(r => setTimeout(r, 0)); - await engine.update('opportunity', { id: 'opp_dup', amount: 110000 }, { context: { ...SYS, roles: ['admin'] } }); - await new Promise(r => setTimeout(r, 0)); - expect((engine._tables.sys_approval_request ?? []).length).toBe(1); - }); - - it('lock hook blocks edits to a locked record', async () => { - await engine.insert('opportunity', { id: 'opp_lock', amount: 100000, stage: 'qualification' }); - await new Promise(r => setTimeout(r, 0)); - await expect( - engine.update('opportunity', { id: 'opp_lock', stage: 'closed_won' }, { context: { userId: 'u1', roles: [] } }), - ).rejects.toThrow(/RECORD_LOCKED/); - }); - - it('lock hook allows admin role override', async () => { - await engine.insert('opportunity', { id: 'opp_admin', amount: 100000, stage: 'qualification' }); - await new Promise(r => setTimeout(r, 0)); - await expect( - engine.update('opportunity', { id: 'opp_admin', stage: 'closed_won' }, { context: { userId: 'admin', roles: ['admin'] } }), - ).resolves.toBeTruthy(); - }); - - it('unbindAllHooks removes registered hooks idempotently', async () => { - const removed = unbindAllHooks(engine as any); - expect(removed).toBeGreaterThan(0); - // Re-bind to default state for any later beforeEach. - const procs = await svc.listProcesses({ activeOnly: true }, SYS as any); - bindProcessHooks(engine as any, svc, procs); - }); - }); -}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 498d6070fd..4c3bdac299 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -3872,19 +3872,17 @@ export class RestServer { } /** - * Register approval engine endpoints. + * Register approval endpoints (ADR-0019: approval as a flow node). + * + * Approval is no longer a standalone process engine — a flow's Approval + * node opens a request and suspends the run; a decision resumes it. There + * are no process-authoring or submit routes anymore. * * Routes (all under {basePath}/approvals): - * GET /processes — list approval processes - * POST /processes — upsert (defineProcess) - * GET /processes/:id — get by id or name - * DELETE /processes/:id — delete process - * POST /requests — submit * GET /requests — list (filters: status, object, recordId, approverId, submitterId) * GET /requests/:id — get request - * POST /requests/:id/approve — approve current step - * POST /requests/:id/reject — reject current step - * POST /requests/:id/recall — recall (submitter only) + * POST /requests/:id/approve — record an approve decision (resumes the flow) + * POST /requests/:id/reject — record a reject decision (resumes the flow) * GET /requests/:id/actions — audit trail * * Returns 501 when `approvalsServiceProvider` is unset so deployments @@ -3892,10 +3890,10 @@ export class RestServer { */ private registerApprovalsEndpoints(basePath: string): void { // Approval routes live at the top of the API surface (e.g. - // `/api/v1/approvals/processes`, `/api/v1/approvals/requests/:id/approve`). - // Approvals are a cross-cutting capability — a request is not a - // record on a single CRUD object, so anchoring it on `basePath` - // (instead of `${basePath}/data`) keeps the URL semantics honest. + // `/api/v1/approvals/requests/:id/approve`). Approvals are a + // cross-cutting capability — a request is not a record on a single + // CRUD object, so anchoring it on `basePath` (instead of + // `${basePath}/data`) keeps the URL semantics honest. const dataPath = basePath; const isScoped = basePath.includes('/environments/:environmentId'); @@ -3915,8 +3913,6 @@ export class RestServer { [/^DUPLICATE_REQUEST/, 409, 'DUPLICATE_REQUEST'], [/^INVALID_STATE/, 409, 'INVALID_STATE'], [/^FORBIDDEN/, 403, 'FORBIDDEN'], - [/^NO_ACTIVE_PROCESS/, 404, 'NO_ACTIVE_PROCESS'], - [/^PROCESS_NOT_FOUND/, 404, 'PROCESS_NOT_FOUND'], [/^REQUEST_NOT_FOUND/, 404, 'REQUEST_NOT_FOUND'], ]; for (const [re, status, code] of mapping) { @@ -3928,134 +3924,7 @@ export class RestServer { return false; }; - // ── Processes ───────────────────────────────────────────── - this.routeManager.register({ - method: 'GET', - path: `${dataPath}/approvals/processes`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - const q = req.query ?? {}; - const rows = await svc.listProcesses({ - object: q.object, - activeOnly: q.activeOnly === 'true' || q.activeOnly === true, - }, context ?? {}); - res.json({ data: rows }); - } catch (error: any) { - logError('[REST] List approval processes error:', error); - res.status(500).json({ code: 'APPROVAL_PROCESS_LIST_FAILED', error: String(error?.message ?? error).slice(0, 500) }); - } - }, - metadata: { summary: 'List approval processes', tags: ['approvals'] }, - }); - - this.routeManager.register({ - method: 'POST', - path: `${dataPath}/approvals/processes`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - try { - const row = await svc.defineProcess(req.body ?? {}, context ?? {}); - res.status(201).json(row); - } catch (err: any) { - if (handleApprovalError(res, err)) return; - throw err; - } - } catch (error: any) { - logError('[REST] Define approval process error:', error); - res.status(500).json({ code: 'APPROVAL_PROCESS_DEFINE_FAILED', error: String(error?.message ?? error).slice(0, 500) }); - } - }, - metadata: { summary: 'Define (upsert) an approval process', tags: ['approvals'] }, - }); - - this.routeManager.register({ - method: 'GET', - path: `${dataPath}/approvals/processes/:id`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - const row = await svc.getProcess(req.params.id, context ?? {}); - if (!row) { - res.status(404).json({ code: 'PROCESS_NOT_FOUND', error: `Approval process '${req.params.id}' not found` }); - return; - } - res.json(row); - } catch (error: any) { - logError('[REST] Get approval process error:', error); - res.status(500).json({ code: 'APPROVAL_PROCESS_GET_FAILED', error: String(error?.message ?? error).slice(0, 500) }); - } - }, - metadata: { summary: 'Get an approval process by id or name', tags: ['approvals'] }, - }); - - this.routeManager.register({ - method: 'DELETE', - path: `${dataPath}/approvals/processes/:id`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - await svc.deleteProcess(req.params.id, context ?? {}); - res.status(204).end(); - } catch (error: any) { - logError('[REST] Delete approval process error:', error); - res.status(500).json({ code: 'APPROVAL_PROCESS_DELETE_FAILED', error: String(error?.message ?? error).slice(0, 500) }); - } - }, - metadata: { summary: 'Delete an approval process', tags: ['approvals'] }, - }); - // ── Requests ────────────────────────────────────────────── - this.routeManager.register({ - method: 'POST', - path: `${dataPath}/approvals/requests`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - const body = req.body ?? {}; - try { - const row = await svc.submit({ - object: body.object, - recordId: body.recordId ?? body.record_id, - processName: body.processName ?? body.process_name, - submitterId: body.submitterId ?? body.submitter_id ?? context?.userId, - comment: body.comment, - payload: body.payload, - }, context ?? {}); - res.status(201).json(row); - } catch (err: any) { - if (handleApprovalError(res, err)) return; - throw err; - } - } catch (error: any) { - logError('[REST] Submit approval error:', error); - res.status(500).json({ code: 'APPROVAL_SUBMIT_FAILED', error: String(error?.message ?? error).slice(0, 500) }); - } - }, - metadata: { summary: 'Submit a record for approval', tags: ['approvals'] }, - }); - this.routeManager.register({ method: 'GET', path: `${dataPath}/approvals/requests`, @@ -4113,10 +3982,14 @@ export class RestServer { metadata: { summary: 'Get an approval request by id', tags: ['approvals'] }, }); - const decisionRoute = (suffix: 'approve' | 'reject' | 'recall', method: 'approve' | 'reject' | 'recall') => { + // Record a decision on a node-driven request. Both branches funnel + // through the contract's `decide()`, which finalizes the request and + // resumes the owning flow run down the matching `approve` / `reject` + // edge. + const decisionRoute = (decision: 'approve' | 'reject') => { this.routeManager.register({ method: 'POST', - path: `${dataPath}/approvals/requests/:id/${suffix}`, + path: `${dataPath}/approvals/requests/:id/${decision}`, handler: async (req: any, res: any) => { try { const environmentId = isScoped ? req.params?.environmentId : undefined; @@ -4126,7 +3999,8 @@ export class RestServer { if (!svc) return respond501(res); const body = req.body ?? {}; try { - const out = await svc[method](req.params.id, { + const out = await svc.decide(req.params.id, { + decision, actorId: body.actorId ?? body.actor_id ?? context?.userId, comment: body.comment, }, context ?? {}); @@ -4136,16 +4010,15 @@ export class RestServer { throw err; } } catch (error: any) { - logError(`[REST] ${suffix} approval error:`, error); - res.status(500).json({ code: `APPROVAL_${suffix.toUpperCase()}_FAILED`, error: String(error?.message ?? error).slice(0, 500) }); + logError(`[REST] ${decision} approval error:`, error); + res.status(500).json({ code: `APPROVAL_${decision.toUpperCase()}_FAILED`, error: String(error?.message ?? error).slice(0, 500) }); } }, - metadata: { summary: `${suffix[0].toUpperCase()}${suffix.slice(1)} an approval request`, tags: ['approvals'] }, + metadata: { summary: `${decision[0].toUpperCase()}${decision.slice(1)} an approval request`, tags: ['approvals'] }, }); }; - decisionRoute('approve', 'approve'); - decisionRoute('reject', 'reject'); - decisionRoute('recall', 'recall'); + decisionRoute('approve'); + decisionRoute('reject'); this.routeManager.register({ method: 'GET', diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index ec35278f32..0115a37938 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -366,53 +366,6 @@ export class AppPlugin implements Plugin { }); } - // ── Auto-register declarative Approval Processes ──────────────── - // Approval processes declared via `defineStack({ approvals })` are - // upserted into the running `approvals` service. The `approvals` - // service itself registers on `kernel:ready`, so we defer to the - // same hook to avoid a chicken-and-egg. - try { - const approvals: any[] = Array.isArray(this.bundle.approvals) - ? this.bundle.approvals - : Array.isArray((this.bundle.manifest || {}).approvals) - ? (this.bundle.manifest as any).approvals - : []; - if (approvals.length > 0) { - ctx.hook('kernel:ready', async () => { - let svc: any; - try { svc = ctx.getService('approvals'); } catch { /* not installed */ } - if (!svc || typeof svc.defineProcess !== 'function') { - ctx.logger.warn('[AppPlugin] approvals service not registered — skipping declarative processes', { - appId, processCount: approvals.length, - }); - return; - } - const sysCtx = { isSystem: true, roles: [], permissions: [] }; - let ok = 0; - for (const proc of approvals) { - try { - await svc.defineProcess({ - name: proc.name, - label: proc.label, - object: proc.object, - description: proc.description, - active: proc.active !== false, - definition: proc, - }, sysCtx); - ok++; - } catch (err: any) { - ctx.logger.warn('[AppPlugin] Failed to register approval process', { - appId, process: proc?.name, error: err?.message ?? String(err), - }); - } - } - ctx.logger.info('[AppPlugin] Registered approval processes', { appId, count: ok }); - }); - } - } catch (err: any) { - ctx.logger.error('[AppPlugin] Failed to schedule approval-process registration', err as Error, { appId }); - } - // ── Auto-register declarative Background Jobs ──────────────────── // Jobs declared via `defineStack({ jobs })` are scheduled against the // running `IJobService` on `kernel:ready` (so the service plugin and diff --git a/packages/spec/src/automation/approval.form.ts b/packages/spec/src/automation/approval.form.ts deleted file mode 100644 index 48c4f243f6..0000000000 --- a/packages/spec/src/automation/approval.form.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { defineForm } from '../ui/view.zod'; - -/** - * Approval Process Metadata Form - * - * Form layout for creating/editing approval process metadata definitions. - */ -export const approvalForm = defineForm({ - schemaId: 'approval', - type: 'simple', - sections: [ - { - label: 'Basics', - description: 'Approval process identity and the object it gates.', - columns: 2, - fields: [ - { field: 'name', required: true, colSpan: 1, helpText: 'Unique identifier (snake_case)' }, - { field: 'label', required: true, colSpan: 1, helpText: 'Display name (e.g., "Contract Approval")' }, - { field: 'object', widget: 'ref:object', required: true, colSpan: 1, helpText: 'Which object needs approval' }, - { field: 'active', colSpan: 1, helpText: 'Enable/disable this approval process' }, - { field: 'description', widget: 'textarea', colSpan: 2, helpText: 'What gets approved and why' }, - ], - }, - { - label: 'Entry rules', - description: 'Who can submit, and what happens to the record while pending.', - collapsible: true, - collapsed: true, - fields: [ - { field: 'entryCriteria', widget: 'textarea', helpText: 'CEL expression: users can submit only when this is true' }, - { field: 'lockRecord', helpText: 'Lock record from editing while approval is pending' }, - { field: 'approvalStatusField', helpText: 'Field name to mirror approval status (e.g., "approval_status")' }, - ], - }, - { - label: 'Steps', - description: 'Ordered approval chain — each step picks the approver and decides routing.', - fields: [ - { - field: 'steps', - type: 'repeater', - required: true, - helpText: 'Approval steps in order — each step defines who approves and what happens', - }, - ], - }, - { - label: 'Escalation & outcomes', - description: 'SLA, escalation, and post-decision actions.', - collapsible: true, - collapsed: true, - fields: [ - { field: 'escalation', type: 'composite', helpText: 'Auto-escalate or auto-approve after timeout' }, - { field: 'onFinalApprove', type: 'repeater', helpText: 'Actions when all steps approved (e.g., update status)' }, - { field: 'onFinalReject', type: 'repeater', helpText: 'Actions when rejected (e.g., notify submitter)' }, - ], - }, - ], -}); diff --git a/packages/spec/src/automation/approval.test.ts b/packages/spec/src/automation/approval.test.ts index 3743b92957..e061da6849 100644 --- a/packages/spec/src/automation/approval.test.ts +++ b/packages/spec/src/automation/approval.test.ts @@ -1,16 +1,17 @@ import { describe, it, expect } from 'vitest'; import { ApproverType, - ApprovalActionType, - ApprovalActionSchema, - ApprovalStepSchema, - ApprovalProcessSchema, - ApprovalProcess, + APPROVAL_NODE_TYPE, + ApprovalDecision, + APPROVAL_BRANCH_LABELS, + ApprovalNodeApproverSchema, + ApprovalEscalationSchema, + ApprovalNodeConfigSchema, } from './approval.zod'; describe('ApproverType', () => { it('should accept all valid approver types', () => { - ['user', 'role', 'manager', 'field', 'queue'].forEach(t => { + ['user', 'role', 'team', 'department', 'manager', 'field', 'queue'].forEach(t => { expect(() => ApproverType.parse(t)).not.toThrow(); }); }); @@ -20,218 +21,45 @@ describe('ApproverType', () => { }); }); -describe('ApprovalActionType', () => { - it('should accept all valid action types', () => { - ['field_update', 'email_alert', 'webhook', 'script', 'connector_action'].forEach(t => { - expect(() => ApprovalActionType.parse(t)).not.toThrow(); - }); - }); - - it('should reject invalid action type', () => { - expect(() => ApprovalActionType.parse('sms')).toThrow(); +describe('Approval node constants (ADR-0019)', () => { + it('exposes the canonical node type and decision branch labels', () => { + expect(APPROVAL_NODE_TYPE).toBe('approval'); + expect(ApprovalDecision.options).toEqual(['approve', 'reject']); + expect(APPROVAL_BRANCH_LABELS).toEqual({ approve: 'approve', reject: 'reject' }); }); }); -describe('ApprovalActionSchema', () => { - it('should accept valid action', () => { - expect(() => ApprovalActionSchema.parse({ - type: 'field_update', - name: 'Set Status', - config: { field: 'status', value: 'approved' }, - })).not.toThrow(); - }); - - it('should accept connector action with optional fields', () => { - expect(() => ApprovalActionSchema.parse({ - type: 'connector_action', - name: 'Notify Slack', - config: { channel: '#approvals' }, - connectorId: 'slack', - actionId: 'send_message', - })).not.toThrow(); - }); - - it('should reject missing type', () => { - expect(() => ApprovalActionSchema.parse({ - name: 'Bad Action', - config: {}, - })).toThrow(); +describe('ApprovalNodeApproverSchema', () => { + it('accepts a typed approver with an optional value', () => { + expect(() => ApprovalNodeApproverSchema.parse({ type: 'user', value: 'u1' })).not.toThrow(); + // manager resolves from the submitter, so value is optional + expect(() => ApprovalNodeApproverSchema.parse({ type: 'manager' })).not.toThrow(); }); - it('should reject missing name', () => { - expect(() => ApprovalActionSchema.parse({ - type: 'webhook', - config: { url: 'https://example.com' }, - })).toThrow(); - }); - - it('should reject missing config', () => { - expect(() => ApprovalActionSchema.parse({ - type: 'email_alert', - name: 'Send Email', - })).toThrow(); + it('rejects an unknown approver type', () => { + expect(() => ApprovalNodeApproverSchema.parse({ type: 'group', value: 'x' })).toThrow(); }); }); -describe('ApprovalStepSchema', () => { - const minimalStep = { - name: 'manager_review', - label: 'Manager Review', - approvers: [{ type: 'manager', value: 'direct_manager' }], - }; +describe('ApprovalNodeConfigSchema', () => { + const minimal = { approvers: [{ type: 'user', value: 'u1' }] }; - it('should accept minimal step with defaults', () => { - const result = ApprovalStepSchema.parse(minimalStep); + it('applies node-level defaults', () => { + const result = ApprovalNodeConfigSchema.parse(minimal); expect(result.behavior).toBe('first_response'); - expect(result.rejectionBehavior).toBe('reject_process'); + expect(result.lockRecord).toBe(true); + expect(result.escalation).toBeUndefined(); }); - it('should accept full step', () => { - expect(() => ApprovalStepSchema.parse({ - name: 'vp_review', - label: 'VP Review', - description: 'VP must approve expenses over $10k', - entryCriteria: 'amount > 10000', + it('accepts a full node config with escalation', () => { + const result = ApprovalNodeConfigSchema.parse({ approvers: [ - { type: 'role', value: 'vp_finance' }, - { type: 'user', value: 'user_001' }, + { type: 'manager' }, + { type: 'role', value: 'finance_team' }, ], behavior: 'unanimous', - rejectionBehavior: 'back_to_previous', - onApprove: [{ type: 'field_update', name: 'Update Status', config: { field: 'status', value: 'vp_approved' } }], - onReject: [{ type: 'email_alert', name: 'Notify Submitter', config: { template: 'rejection' } }], - })).not.toThrow(); - }); - - it('should reject invalid name (not snake_case)', () => { - expect(() => ApprovalStepSchema.parse({ - ...minimalStep, - name: 'ManagerReview', - })).toThrow(); - }); - - it('should reject empty approvers array', () => { - expect(() => ApprovalStepSchema.parse({ - ...minimalStep, - approvers: [], - })).toThrow(); - }); - - it('should reject missing approvers', () => { - expect(() => ApprovalStepSchema.parse({ - name: 'bad_step', - label: 'Bad Step', - })).toThrow(); - }); -}); - -describe('ApprovalProcessSchema', () => { - const minimalProcess = { - name: 'expense_approval', - label: 'Expense Approval', - object: 'expense_report', - steps: [{ - name: 'manager_review', - label: 'Manager Review', - approvers: [{ type: 'manager', value: 'direct_manager' }], - }], - }; - - it('should accept minimal process with defaults', () => { - const result = ApprovalProcessSchema.parse(minimalProcess); - expect(result.active).toBe(false); - expect(result.lockRecord).toBe(true); - }); - - it('should accept full process', () => { - expect(() => ApprovalProcessSchema.parse({ - name: 'purchase_approval', - label: 'Purchase Approval', - object: 'purchase_order', - active: true, - description: 'Multi-step purchase approval', - entryCriteria: 'amount > 1000', lockRecord: false, - steps: [ - { - name: 'manager_review', - label: 'Manager Review', - approvers: [{ type: 'manager', value: 'direct_manager' }], - }, - { - name: 'finance_review', - label: 'Finance Review', - entryCriteria: 'amount > 5000', - approvers: [{ type: 'role', value: 'finance_team' }], - behavior: 'unanimous', - }, - ], - onSubmit: [{ type: 'field_update', name: 'Lock', config: { field: 'status', value: 'submitted' } }], - onFinalApprove: [{ type: 'email_alert', name: 'Approved', config: { template: 'approved' } }], - onFinalReject: [{ type: 'webhook', name: 'Notify', config: { url: 'https://example.com/reject' } }], - onRecall: [{ type: 'field_update', name: 'Reset', config: { field: 'status', value: 'draft' } }], - })).not.toThrow(); - }); - - it('should reject empty steps array', () => { - expect(() => ApprovalProcessSchema.parse({ - ...minimalProcess, - steps: [], - })).toThrow(); - }); - - it('should reject invalid process name', () => { - expect(() => ApprovalProcessSchema.parse({ - ...minimalProcess, - name: 'ExpenseApproval', - })).toThrow(); - }); - - it('should reject missing object', () => { - expect(() => ApprovalProcessSchema.parse({ - name: 'test_process', - label: 'Test', - steps: [{ - name: 'step_one', - label: 'Step One', - approvers: [{ type: 'user', value: 'admin' }], - }], - })).toThrow(); - }); -}); - -describe('ApprovalProcess.create', () => { - it('should return the config object as-is', () => { - const config = { - name: 'quick_approval', - label: 'Quick Approval', - object: 'invoice', - steps: [{ - name: 'review', - label: 'Review', - approvers: [{ type: 'role' as const, value: 'reviewer' }], - }], - }; - const result = ApprovalProcess.create(config); - expect(result).toEqual(config); - }); -}); - -// ============================================================================ -// Protocol Improvement Tests: Approval escalation -// ============================================================================ - -describe('ApprovalProcessSchema - escalation', () => { - it('should accept approval process with escalation config', () => { - const result = ApprovalProcessSchema.parse({ - name: 'escalated_approval', - label: 'Escalated Approval', - object: 'purchase_order', - steps: [{ - name: 'manager_review', - label: 'Manager Review', - approvers: [{ type: 'manager', value: 'direct_manager' }], - }], + approvalStatusField: 'approval_status', escalation: { enabled: true, timeoutHours: 48, @@ -240,61 +68,26 @@ describe('ApprovalProcessSchema - escalation', () => { notifySubmitter: true, }, }); - expect(result.escalation?.enabled).toBe(true); - expect(result.escalation?.timeoutHours).toBe(48); + expect(result.behavior).toBe('unanimous'); + expect(result.lockRecord).toBe(false); + expect(result.approvalStatusField).toBe('approval_status'); expect(result.escalation?.action).toBe('reassign'); - expect(result.escalation?.escalateTo).toBe('vp_operations'); }); - it('should accept escalation with auto_approve action', () => { - const result = ApprovalProcessSchema.parse({ - name: 'auto_escalate', - label: 'Auto Escalate', - object: 'expense_report', - steps: [{ - name: 'review', - label: 'Review', - approvers: [{ type: 'user', value: 'finance_team' }], - }], - escalation: { - enabled: true, - timeoutHours: 72, - action: 'auto_approve', - }, - }); - expect(result.escalation?.action).toBe('auto_approve'); + it('rejects an empty approvers array', () => { + expect(() => ApprovalNodeConfigSchema.parse({ approvers: [] })).toThrow(); }); - it('should default escalation action to notify', () => { - const result = ApprovalProcessSchema.parse({ - name: 'default_escalation', - label: 'Default', - object: 'request', - steps: [{ - name: 'step_one', - label: 'Step 1', - approvers: [{ type: 'role', value: 'approver' }], - }], - escalation: { - enabled: true, - timeoutHours: 24, - }, - }); - expect(result.escalation?.action).toBe('notify'); - expect(result.escalation?.notifySubmitter).toBe(true); + it('rejects an unknown behavior', () => { + expect(() => ApprovalNodeConfigSchema.parse({ ...minimal, behavior: 'quorum' })).toThrow(); }); +}); - it('should accept approval process without escalation (optional)', () => { - const result = ApprovalProcessSchema.parse({ - name: 'no_escalation', - label: 'No Escalation', - object: 'task', - steps: [{ - name: 'approve', - label: 'Approve', - approvers: [{ type: 'user', value: 'admin' }], - }], - }); - expect(result.escalation).toBeUndefined(); +describe('ApprovalEscalationSchema', () => { + it('defaults action to notify and requires a positive timeout', () => { + const result = ApprovalEscalationSchema.parse({ enabled: true, timeoutHours: 24 }); + expect(result.action).toBe('notify'); + expect(result.notifySubmitter).toBe(true); + expect(() => ApprovalEscalationSchema.parse({ enabled: true, timeoutHours: 0 })).toThrow(); }); }); diff --git a/packages/spec/src/automation/approval.zod.ts b/packages/spec/src/automation/approval.zod.ts index 1b79b46d8f..f717a6fc89 100644 --- a/packages/spec/src/automation/approval.zod.ts +++ b/packages/spec/src/automation/approval.zod.ts @@ -1,13 +1,11 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; -import { ExpressionInputSchema } from '../shared/expression.zod'; +import { lazySchema } from '../shared/lazy-schema'; /** * Approval Step Approver Type */ -import { lazySchema } from '../shared/lazy-schema'; export const ApproverType = z.enum([ 'user', // Specific user(s) 'role', // Users with specific role (sys_member.role) @@ -18,142 +16,22 @@ export const ApproverType = z.enum([ 'queue' // Data ownership queue ]); -/** - * Approval Action Type - * Actions to execute on transition - * - * @deprecated ADR-0019 — actions are no longer attached to approval steps. - * In the flow model, "on approve / on reject" work is expressed as the - * downstream nodes wired to the Approval node's `approve` / `reject` out-edges - * (any registered action node — http, notify, update_record, …). Retained only - * until {@link ApprovalProcessSchema} is removed. - */ -export const ApprovalActionType = z.enum([ - 'field_update', - 'email_alert', - 'webhook', - 'script', - 'connector_action', // Added for Zapier-style integrations - 'inbox_notify', // M11.C15.B — write a sys_notification row -]); - -/** - * definition of an action to perform - */ -export const ApprovalActionSchema = lazySchema(() => z.object({ - type: ApprovalActionType, - name: z.string().describe('Action name'), - config: z.record(z.string(), z.unknown()).describe('Action configuration'), - - /** For connector actions */ - connectorId: z.string().optional(), - actionId: z.string().optional(), -})); - -/** - * Approval Process Step - */ -export const ApprovalStepSchema = lazySchema(() => z.object({ - name: SnakeCaseIdentifierSchema.describe('Step machine name'), - label: z.string().describe('Step display label'), - description: z.string().optional(), - - /** Entry criteria for this step */ - entryCriteria: ExpressionInputSchema.optional().describe('Predicate (CEL) to enter this step.'), - - /** Who can approve */ - approvers: z.array(z.object({ - type: ApproverType, - value: z.string().describe('User ID, Role Name, or Field Name') - })).min(1).describe('List of allowed approvers'), - - /** Approval Logic */ - behavior: z.enum(['first_response', 'unanimous']).default('first_response') - .describe('How to handle multiple approvers'), - - /** Rejection behavior */ - rejectionBehavior: z.enum(['reject_process', 'back_to_previous']) - .default('reject_process').describe('What happens if rejected'), - - /** Actions */ - onApprove: z.array(ApprovalActionSchema).optional().describe('Actions on step approval'), - onReject: z.array(ApprovalActionSchema).optional().describe('Actions on step rejection'), -})); - -/** - * Approval Process Protocol - * - * Defines a complex review and approval cycle for a record. - * Manages state locking, notifications, and transition logic. - * - * @deprecated ADR-0019 — the standalone approval *authoring* type is collapsed - * into Flow. An approval is now authored as a flow with one or more **Approval - * nodes** (see {@link ApprovalNodeConfigSchema}); the engine rides its durable - * pause. The process-level concepts re-home as follows: - * - `steps` → successive Approval nodes on the canvas - * - `entryCriteria` → the condition on the edge entering the node - * - `onApprove`/`onReject` → the nodes wired to the node's `approve`/`reject` edges - * - `rejectionBehavior: back_to_previous` → a back-edge to an earlier node - * - `lockRecord` / `approvalStatusField` / `escalation` / `behavior` / approvers - * → {@link ApprovalNodeConfigSchema} node config - * This schema is retained only for the migration window and will be removed. - */ -export const ApprovalProcessSchema = lazySchema(() => z.object({ - name: SnakeCaseIdentifierSchema.describe('Unique process name'), - label: z.string().describe('Human readable label'), - object: z.string().describe('Target Object Name'), - - active: z.boolean().default(false), - description: z.string().optional(), - - /** Entry Criteria for the entire process */ - entryCriteria: ExpressionInputSchema.optional().describe('Predicate (CEL) to allow submission.'), - - /** Record Locking */ - lockRecord: z.boolean().default(true).describe('Lock record from editing during approval'), - - /** - * M11.C15.B — name of a field on the business object where the - * engine mirrors the request status (e.g. `'approval_status'`). - * Values written: 'pending' | 'approved' | 'rejected' | 'recalled' - * | 'not_submitted'. The field should be declared as readonly on - * the object so users can filter / display it but not edit it. - * If omitted, no status mirror is written and the engine only - * exposes status via `sys_approval_request`. - */ - approvalStatusField: z.string().optional().describe( - 'Field name on the business object to mirror the request status.', - ), - - /** Steps */ - steps: z.array(ApprovalStepSchema).min(1).describe('Sequence of approval steps'), - - /** Escalation Configuration (SLA-based auto-escalation) */ - escalation: z.object({ - enabled: z.boolean().default(false).describe('Enable SLA-based escalation'), - timeoutHours: z.number().min(1).describe('Hours before escalation triggers'), - action: z.enum(['reassign', 'auto_approve', 'auto_reject', 'notify']).default('notify').describe('Action to take on escalation timeout'), - escalateTo: z.string().optional().describe('User ID, role, or manager level to escalate to'), - notifySubmitter: z.boolean().default(true).describe('Notify the original submitter on escalation'), - }).optional().describe('SLA escalation configuration for pending approval steps'), - - /** Global Actions */ - onSubmit: z.array(ApprovalActionSchema).optional().describe('Actions on initial submission'), - onFinalApprove: z.array(ApprovalActionSchema).optional().describe('Actions on final approval'), - onFinalReject: z.array(ApprovalActionSchema).optional().describe('Actions on final rejection'), - onRecall: z.array(ApprovalActionSchema).optional().describe('Actions on recall'), -})); - -export const ApprovalProcess = Object.assign(ApprovalProcessSchema, { - create: >(config: T) => config, -}); - -export type ApprovalProcess = z.infer; -export type ApprovalStep = z.infer; - // ========================================================================== // Approval as a Flow Node (ADR-0019, canonical) // ========================================================================== +// +// ADR-0019 collapsed the standalone approval *authoring* type into Flow. An +// approval is now authored as a flow with one or more **Approval nodes** +// (`type: 'approval'`); the engine rides the node's durable pause. The former +// process-level concepts re-home as: +// - `steps` → successive Approval nodes on the canvas +// - `entryCriteria` → the condition on the edge entering the node +// - `onApprove`/`onReject` → the nodes wired to the node's `approve`/`reject` edges +// - `rejectionBehavior: back_to_previous` → a back-edge to an earlier node +// - `lockRecord` / `approvalStatusField` / `escalation` / `behavior` / approvers +// → {@link ApprovalNodeConfigSchema} node config +// The process-driven schemas (ApprovalProcessSchema / ApprovalStepSchema / +// ApprovalActionSchema) were removed in ADR-0019 P4. /** * Registry node type for the Approval node. The `plugin-approvals` package @@ -194,8 +72,8 @@ export const ApprovalNodeApproverSchema = lazySchema(() => z.object({ export type ApprovalNodeApprover = z.infer; /** - * Per-node SLA escalation — lowered from {@link ApprovalProcessSchema.escalation} - * to the node, so each Approval step on the canvas carries its own SLA. + * Per-node SLA escalation — carried on the Approval node itself, so each + * Approval step on the canvas defines its own SLA. */ export const ApprovalEscalationSchema = lazySchema(() => z.object({ enabled: z.boolean().default(false).describe('Enable SLA-based escalation for this node'), diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index 6705883e55..bd4330647d 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -6,7 +6,6 @@ export { flowForm } from './flow.form'; export * from './execution.zod'; export * from './webhook.zod'; export * from './approval.zod'; -export { approvalForm } from './approval.form'; export * from './etl.zod'; export * from './trigger-registry.zod'; export * from './sync.zod'; diff --git a/packages/spec/src/automation/node-executor.zod.ts b/packages/spec/src/automation/node-executor.zod.ts index de9e4cd468..19ecbf5b50 100644 --- a/packages/spec/src/automation/node-executor.zod.ts +++ b/packages/spec/src/automation/node-executor.zod.ts @@ -200,8 +200,9 @@ export type ActionParadigm = z.infer; * * This is the single source of truth for "what a node/action is" — the * shape a plugin publishes when it registers an executor. It supersedes the - * three closed enums (`FlowNodeAction`, `WorkflowAction`, `ApprovalActionType`), - * which become *seed* descriptor sets registered at boot. + * closed enums (`FlowNodeAction`, `WorkflowAction`), which become *seed* + * descriptor sets registered at boot. (ADR-0019 removed the third such enum, + * `ApprovalActionType`, along with the standalone approval process type.) * * The runtime registry (`AutomationEngine.getActionDescriptors()`) aggregates * these and backs both: diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index 94bc069220..a44ac3c8e1 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -3,14 +3,17 @@ /** * @objectstack/spec/contracts/approval-service * - * Cross-package contract for the multi-step approval engine. The - * default implementation lives in `@objectstack/plugin-approvals` and - * is registered as the `approvals` service. + * Cross-package contract for the approval runtime. The default + * implementation lives in `@objectstack/plugin-approvals` and is registered + * as the `approvals` service. * - * Sits on top of (but does not depend on) `IWorkflowService`: a - * workflow is a single state machine on a record; an approval process - * is a *cycle* — submit → review → approve/reject → effects — driven - * by humans rather than transition rules. + * ADR-0019: approval is no longer a standalone engine. An approval is a + * **flow node** (`type: 'approval'`) — the flow opens a request on the node + * and suspends; a human decision finalises it and resumes the flow down the + * matching `approve` / `reject` edge. This service owns the runtime state + * (`sys_approval_request` / `sys_approval_action`, approver resolution, record + * lock, status mirror) and the decision API. There is no standalone process + * authoring type, submit, or step machinery anymore. */ import type { SharingExecutionContext } from './sharing-service.js'; @@ -18,38 +21,24 @@ import type { SharingExecutionContext } from './sharing-service.js'; /** Lifecycle state of an approval request. */ export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'recalled'; -/** Stored process definition row. */ -export interface ApprovalProcessRow { - id: string; - name: string; - label: string; - object_name: string; - description?: string; - active: boolean; - definition: any; - created_at?: string; - updated_at?: string; -} - /** Live request row. */ export interface ApprovalRequestRow { id: string; + /** Origin of the request — `flow:` for node-driven approvals. */ process_name: string; - /** - * sha256 of the approval process body at submit time (ADR-0009 execution pinning). - * When set, the engine resolves the process via `MetadataRepository.getByHash` - * so process upgrades do not affect this in-flight request. - */ - process_hash?: string; object_name: string; record_id: string; submitter_id?: string; submitter_comment?: string; status: ApprovalStatus; + /** The flow node id that opened the request (mirrors `flow_node_id`). */ current_step?: string; current_step_index?: number; pending_approvers?: string[]; payload?: unknown; + /** ADR-0019 correlation: the suspended flow run this request belongs to. */ + flow_run_id?: string; + flow_node_id?: string; completed_at?: string; created_at?: string; updated_at?: string; @@ -67,56 +56,29 @@ export interface ApprovalActionRow { created_at?: string; } -/** Input for `IApprovalService.defineProcess`. */ -export interface DefineApprovalProcessInput { - id?: string; - name: string; - label: string; - object: string; - description?: string; - active?: boolean; - /** The full ApprovalProcess JSON envelope. */ - definition: any; -} - -/** Input for `IApprovalService.submit`. */ -export interface SubmitApprovalInput { - object: string; - recordId: string; - /** Optional — when omitted the engine picks the active process for the object. */ - processName?: string; - submitterId?: string; - comment?: string; - /** Snapshot of the record at submission time. Optional but useful for emails. */ - payload?: unknown; -} - -/** Input for approve / reject / recall. */ +/** Input for a decision on an approval request. */ export interface ApprovalDecisionInput { + decision: 'approve' | 'reject'; actorId: string; comment?: string; } -/** Result of a single decision call. */ +/** Result of a decision that resumes the owning flow when finalised. */ export interface ApprovalDecisionResult { request: ApprovalRequestRow; /** True when this call moved the request to a terminal state. */ finalized: boolean; + decision: 'approve' | 'reject'; + /** The suspended flow run that was (or will be) resumed, if any. */ + runId?: string | null; + /** True when the owning flow run was resumed as a result of this decision. */ + resumed?: boolean; } /** - * Public contract. + * Public contract — the node-era approval runtime. */ export interface IApprovalService { - // ── Process definitions ────────────────────────────────────── - defineProcess(input: DefineApprovalProcessInput, context: SharingExecutionContext): Promise; - listProcesses(filter: { object?: string; activeOnly?: boolean } | undefined, context: SharingExecutionContext): Promise; - getProcess(idOrName: string, context: SharingExecutionContext): Promise; - deleteProcess(idOrName: string, context: SharingExecutionContext): Promise; - - // ── Requests ───────────────────────────────────────────────── - submit(input: SubmitApprovalInput, context: SharingExecutionContext): Promise; - /** * "My approvals" inbox. Supports filtering by status, target object, * record id, or by the user expected to act next. @@ -134,14 +96,12 @@ export interface IApprovalService { getRequest(requestId: string, context: SharingExecutionContext): Promise; - /** Approve the current step. Advances to the next, or finalises. */ - approve(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise; - - /** Reject the current step. Finalises (or rolls back, per step config). */ - reject(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise; - - /** Submitter or admin cancels a pending request. */ - recall(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise; + /** + * Record a decision on a node-driven request. Honours the node's + * `unanimous` behaviour, finalises the request when satisfied, and resumes + * the owning flow run down the matching `approve` / `reject` edge. + */ + decide(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise; /** Audit trail for a request. */ listActions(requestId: string, context: SharingExecutionContext): Promise; diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index b9dca16431..17708bb6e2 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -366,8 +366,8 @@ const ObjectSchemaBase = z.object({ * CSV Import is suppressed (config rows have nested JSON envelopes * that don't round-trip through a flat sheet; clients should offer a * purpose-built "Import definition (JSON)" action instead). Example: - * `sys_approval_process`, `sys_sharing_rule`, `sys_role`, - * `sys_permission_set`, `sys_view`, `sys_app`. + * `sys_sharing_rule`, `sys_role`, `sys_permission_set`, `sys_view`, + * `sys_app`. * - `system` — Runtime rows whose lifecycle is owned by a * platform service (the approval engine, the sharing engine, the * invitation service, …). Generic CRUD is hidden — users interact diff --git a/packages/spec/src/kernel/metadata-type-schemas.ts b/packages/spec/src/kernel/metadata-type-schemas.ts index 3f179d3c0a..75dc9eef60 100644 --- a/packages/spec/src/kernel/metadata-type-schemas.ts +++ b/packages/spec/src/kernel/metadata-type-schemas.ts @@ -43,7 +43,6 @@ import { ReportSchema } from '../ui/report.zod'; import { FlowSchema } from '../automation/flow.zod'; import { StateMachineSchema } from '../automation/state-machine.zod'; -import { ApprovalProcessSchema } from '../automation/approval.zod'; import { JobSchema } from '../system/job.zod'; import { EmailTemplateDefinitionSchema } from '../system/email-template.zod'; @@ -84,7 +83,8 @@ const BUILTIN_METADATA_TYPE_SCHEMAS: Partial> = // Automation Protocol flow: FlowSchema, workflow: StateMachineSchema, - approval: ApprovalProcessSchema, + // ADR-0019: `approval` is no longer a standalone metadata type — approvals + // are authored as Approval nodes inside a `flow`. job: JobSchema, // System Protocol diff --git a/packages/spec/src/shared/metadata-collection.test.ts b/packages/spec/src/shared/metadata-collection.test.ts index 111b53d13b..d8a082fc9c 100644 --- a/packages/spec/src/shared/metadata-collection.test.ts +++ b/packages/spec/src/shared/metadata-collection.test.ts @@ -249,7 +249,6 @@ describe('MAP_SUPPORTED_FIELDS', () => { expect(MAP_SUPPORTED_FIELDS).toContain('actions'); expect(MAP_SUPPORTED_FIELDS).toContain('themes'); expect(MAP_SUPPORTED_FIELDS).toContain('workflows'); - expect(MAP_SUPPORTED_FIELDS).toContain('approvals'); expect(MAP_SUPPORTED_FIELDS).toContain('flows'); expect(MAP_SUPPORTED_FIELDS).toContain('roles'); expect(MAP_SUPPORTED_FIELDS).toContain('permissions'); diff --git a/packages/spec/src/shared/metadata-collection.zod.ts b/packages/spec/src/shared/metadata-collection.zod.ts index e3546fdf4f..0a88d51706 100644 --- a/packages/spec/src/shared/metadata-collection.zod.ts +++ b/packages/spec/src/shared/metadata-collection.zod.ts @@ -77,7 +77,6 @@ export const MAP_SUPPORTED_FIELDS = [ 'actions', 'themes', 'workflows', - 'approvals', 'flows', 'jobs', 'roles', @@ -116,7 +115,6 @@ export const PLURAL_TO_SINGULAR: Record = { actions: 'action', themes: 'theme', workflows: 'workflow', - approvals: 'approval', flows: 'flow', jobs: 'job', roles: 'role', diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index de9e814451..5eb64c4a16 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -459,24 +459,6 @@ describe('defineStack', () => { expect(() => defineStack(config as any, { strict: true })).toThrow('defineStack validation failed'); }); - it('should detect approval referencing undefined object in strict mode', () => { - const config = { - manifest: baseManifest, - objects: [ - { name: 'deal', fields: { amount: { type: 'number' } } }, - ], - approvals: [ - { - name: 'deal_approval', - label: 'Deal Approval', - object: 'missing_object', - steps: [{ name: 'step1', label: 'Step 1', approvers: [{ type: 'manager', value: 'mgr' }] }], - }, - ], - }; - expect(() => defineStack(config, { strict: true })).toThrow('missing_object'); - }); - it('should detect hook referencing undefined object in strict mode', () => { const config = { manifest: baseManifest, diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index c0acae0eb2..ce5335dc55 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -24,7 +24,6 @@ import { ActionSchema } from './ui/action.zod'; import { ThemeSchema } from './ui/theme.zod'; // Automation Protocol -import { ApprovalProcessSchema } from './automation/approval.zod'; import { StateMachineSchema } from './automation/state-machine.zod'; import { FlowSchema } from './automation/flow.zod'; import { JobSchema } from './system/job.zod'; @@ -212,12 +211,14 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ actions: z.array(ActionSchema).optional().describe('Global and Object Actions'), themes: z.array(ThemeSchema).optional().describe('UI Themes'), - /** - * ObjectFlow: Automation Layer + /** + * ObjectFlow: Automation Layer * Business logic, approvals, and workflows. + * + * ADR-0019: approvals are no longer a top-level collection — an approval is + * authored as a flow with one or more Approval nodes, so it lives in `flows`. */ workflows: z.array(StateMachineSchema).optional().describe('State-machine workflow definitions (record lifecycle state management)'), - approvals: z.array(ApprovalProcessSchema).optional().describe('Approval processes'), flows: z.array(FlowSchema).optional().describe('Screen Flows'), jobs: z.array(JobSchema).optional().describe('Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules)'), emailTemplates: z.array(EmailTemplateDefinitionSchema).optional().describe('Email Templates resolved by IEmailService.sendTemplate({ template, locale })'), @@ -502,17 +503,6 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { if (objectNames.size === 0) return errors; - // Validate approval → object references - if (config.approvals) { - for (const approval of config.approvals) { - if (approval.object && !objectNames.has(approval.object)) { - errors.push( - `Approval '${approval.name}' references object '${approval.object}' which is not defined in objects.`, - ); - } - } - } - // Validate hook → object references if (config.hooks) { for (const hook of config.hooks) { @@ -848,7 +838,6 @@ const CONCAT_ARRAY_FIELDS = [ 'actions', 'themes', 'workflows', - 'approvals', 'flows', 'roles', 'permissions', diff --git a/packages/spec/src/system/metadata-form-registry.ts b/packages/spec/src/system/metadata-form-registry.ts index 145dd5d60c..0d81b5234f 100644 --- a/packages/spec/src/system/metadata-form-registry.ts +++ b/packages/spec/src/system/metadata-form-registry.ts @@ -44,7 +44,7 @@ import { import { roleForm } from '../identity'; import { permissionForm } from '../security'; import { agentForm, toolForm, skillForm } from '../ai'; -import { flowForm, approvalForm } from '../automation'; +import { flowForm } from '../automation'; import { emailTemplateForm } from './email-template.form'; /** @@ -77,7 +77,6 @@ export const METADATA_FORM_REGISTRY: Readonly> = Object tool: toolForm, skill: skillForm, flow: flowForm, - approval: approvalForm, permission: permissionForm, profile: permissionForm, email_template: emailTemplateForm,