From 33e3614c1f600fb98d983e484260acd9d9c297bf Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:22:39 +0800 Subject: [PATCH 1/2] feat(approvals): surface decisionOutputs keys on the request row (#3447 P2 UI enablement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decision UI renders one input per author-declared output key; the set varies per request (each node declares its own), so it rides the request read (decision_outputs) rather than the object's static action params — the params array cannot enumerate a per-request field set. Co-Authored-By: Claude Fable 5 --- .changeset/decision-outputs-surface-3447.md | 6 ++++++ .../plugin-approvals/src/approval-service.test.ts | 15 +++++++++++++++ .../plugin-approvals/src/approval-service.ts | 7 +++++++ packages/spec/src/contracts/approval-service.ts | 7 +++++++ 4 files changed, 35 insertions(+) create mode 100644 .changeset/decision-outputs-surface-3447.md diff --git a/.changeset/decision-outputs-surface-3447.md b/.changeset/decision-outputs-surface-3447.md new file mode 100644 index 0000000000..c4f680a374 --- /dev/null +++ b/.changeset/decision-outputs-surface-3447.md @@ -0,0 +1,6 @@ +--- +"@objectstack/spec": patch +"@objectstack/plugin-approvals": patch +--- + +Surface the approval node's author-declared `decisionOutputs` keys on the request read as `ApprovalRequestRow.decision_outputs` (#3447 P2 UI enablement). The set varies per request (each node declares its own), so it rides the row rather than the object's static action params — a decision UI renders one input per key and POSTs `outputs` with the decision. diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index ecf8126920..3e48398e17 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -441,6 +441,21 @@ describe('ApprovalService (node era)', () => { }, SYS)).rejects.toThrow(/VALIDATION_FAILED.*reserved/s); }); + it('decision outputs: declared keys surface on the request row as decision_outputs (#3447 P2 UI)', async () => { + // The decision UI renders one input per declared key — the keys ride the + // request read (per-request; the static action params can't carry them). + const req = await svc.openNodeRequest( + openInput(['u9'], {}, { decisionOutputs: ['next_reviewers', 'note'] }), CTX, + ) as any; + expect(req.decision_outputs).toEqual(['next_reviewers', 'note']); + const listed = await svc.listRequests({ status: 'pending' }, SYS); + expect((listed[0] as any).decision_outputs).toEqual(['next_reviewers', 'note']); + // A node declaring none omits the field entirely. + engine._tables['sys_approval_request'] = []; + const plain = await svc.openNodeRequest(openInput(['u9']), CTX) as any; + expect(plain.decision_outputs).toBeUndefined(); + }); + it('decision outputs: accepted keys return from decideNode and snapshot as __decisionOutputs', async () => { const req = await svc.openNodeRequest( openInput(['u9'], {}, { decisionOutputs: ['next_reviewers', 'note'] }), CTX, diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index eec7558359..a409f4be82 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -229,6 +229,13 @@ function rowFromRequest(row: any): ApprovalRequestRow { sla_due_at: slaDueAt(row.created_at, cfg), // ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1). round: typeof cfg?.__round === 'number' ? cfg.__round : undefined, + // #3447 P2: the node's author-declared decision-output keys, surfaced so a + // decision UI can render input fields for them and POST `outputs` on + // approve/reject. Per-request (each node declares its own), which is why + // this rides the row instead of the static action params. + decision_outputs: Array.isArray(cfg?.decisionOutputs) && cfg.decisionOutputs.length + ? cfg.decisionOutputs.map(String) + : undefined, } as any; } diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index a0fa78707a..58970171b7 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -48,6 +48,13 @@ export interface ApprovalRequestRow { /** ADR-0019 correlation: the suspended flow run this request belongs to. */ flow_run_id?: string; flow_node_id?: string; + /** + * #3447 P2: the node's author-declared decision-output keys + * (`config.decisionOutputs`), surfaced from the config snapshot so a + * decision UI can render one input per key and POST `outputs` with the + * decision. Absent when the node declares none. + */ + decision_outputs?: string[]; completed_at?: string; created_at?: string; updated_at?: string; From b857a7a9a5c1067033aae8211bede8e294e52110 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:47:42 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(showcase):=20dynamic-approval=20demo?= =?UTF-8?q?=20flow=20=E2=80=94=20decision=20outputs=20feed=20the=20next=20?= =?UTF-8?q?stage's=20expression=20approvers=20(#3447)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lead reviewer picks the co-signers in their decision (decisionOutputs); the co-sign node resolves them at entry from vars.lead_review.next_reviewers. Browser-dogfooded end to end: dialog renders the declared output field, the decide body nests outputs, and the co-sign slate resolves to the picked ids. Co-Authored-By: Claude Fable 5 --- .../automation/flows/dynamic-approval.flow.ts | 68 +++++++++++++++++++ .../src/automation/flows/index.ts | 3 + 2 files changed, 71 insertions(+) create mode 100644 examples/app-showcase/src/automation/flows/dynamic-approval.flow.ts diff --git a/examples/app-showcase/src/automation/flows/dynamic-approval.flow.ts b/examples/app-showcase/src/automation/flows/dynamic-approval.flow.ts new file mode 100644 index 0000000000..8a15fee652 --- /dev/null +++ b/examples/app-showcase/src/automation/flows/dynamic-approval.flow.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineFlow } from '@objectstack/spec'; + +/** + * #3447 P2 dogfood: dynamic approver routing end to end. + * + * Two-stage approval where the FIRST approver picks the second stage's + * approvers in their decision (`decisionOutputs`), and the second stage + * resolves them at node entry from an `expression` approver over `vars.*` — + * no record-field detour. Trigger: retitling an announcement (an otherwise + * approval-free object, so this demo never collides with the expense/invoice/ + * project approval flows — the service dedupes pending requests per record). + */ +export const DynamicApprovalFlow = defineFlow({ + name: 'showcase_dynamic_approval', + label: 'Announcement Dynamic Approval', + description: 'Lead reviewer picks the co-signers in their decision; the co-sign step resolves them at entry.', + type: 'autolaunched', + status: 'active', + nodes: [ + { + id: 'start', + type: 'start', + label: 'On Announcement Retitled', + config: { + objectName: 'showcase_announcement', + triggerType: 'record-after-update', + condition: 'title != previous.title', + }, + }, + { + id: 'lead_review', + type: 'approval', + label: 'Lead Review', + config: { + // The seeded admin holds the org-membership `owner` tier, so the demo + // routes stage 1 to them without hard-coding a user id. + approvers: [{ type: 'org_membership_level', value: 'owner' }], + behavior: 'first_response', + lockRecord: true, + // The lead hands the co-signers to the flow with their decision. + decisionOutputs: ['next_reviewers'], + }, + }, + { + id: 'co_sign', + type: 'approval', + label: 'Co-sign', + config: { + // Resolved at node entry from the lead's decision outputs (#3447 P2). + approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }], + behavior: 'unanimous', + lockRecord: true, + onEmptyApprovers: 'fail', + }, + }, + { id: 'approved', type: 'end', label: 'Approved' }, + { id: 'rejected', type: 'end', label: 'Rejected' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'lead_review' }, + { id: 'e2', source: 'lead_review', target: 'co_sign', label: 'approve' }, + { id: 'e3', source: 'lead_review', target: 'rejected', label: 'reject' }, + { id: 'e4', source: 'co_sign', target: 'approved', label: 'approve' }, + { id: 'e5', source: 'co_sign', target: 'rejected', label: 'reject' }, + ], +}); diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 6888f90faf..8a1fd07a38 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { defineFlow } from '@objectstack/spec'; +import { DynamicApprovalFlow } from './dynamic-approval.flow'; /** * Task Completed → Notify — an autolaunched, record-triggered flow that fires @@ -1620,4 +1621,6 @@ export const allFlows = [ ResilientSyncFlow, ProjectEscalationFlow, InboundTaskWebhookFlow, + // #3447 P2 dogfood: expression approvers + decision outputs, end to end. + DynamicApprovalFlow, ];