Skip to content

Commit 376a061

Browse files
os-zhuangclaude
andauthored
feat(approvals): surface decisionOutputs keys on the request row (#3447 P2 UI enablement) (#3538)
* feat(approvals): surface decisionOutputs keys on the request row (#3447 P2 UI enablement) 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 <noreply@anthropic.com> * feat(showcase): dynamic-approval demo flow — decision outputs feed the next stage's expression approvers (#3447) 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 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 394b7a1 commit 376a061

6 files changed

Lines changed: 106 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/plugin-approvals": patch
4+
---
5+
6+
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.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { defineFlow } from '@objectstack/spec';
4+
5+
/**
6+
* #3447 P2 dogfood: dynamic approver routing end to end.
7+
*
8+
* Two-stage approval where the FIRST approver picks the second stage's
9+
* approvers in their decision (`decisionOutputs`), and the second stage
10+
* resolves them at node entry from an `expression` approver over `vars.*` —
11+
* no record-field detour. Trigger: retitling an announcement (an otherwise
12+
* approval-free object, so this demo never collides with the expense/invoice/
13+
* project approval flows — the service dedupes pending requests per record).
14+
*/
15+
export const DynamicApprovalFlow = defineFlow({
16+
name: 'showcase_dynamic_approval',
17+
label: 'Announcement Dynamic Approval',
18+
description: 'Lead reviewer picks the co-signers in their decision; the co-sign step resolves them at entry.',
19+
type: 'autolaunched',
20+
status: 'active',
21+
nodes: [
22+
{
23+
id: 'start',
24+
type: 'start',
25+
label: 'On Announcement Retitled',
26+
config: {
27+
objectName: 'showcase_announcement',
28+
triggerType: 'record-after-update',
29+
condition: 'title != previous.title',
30+
},
31+
},
32+
{
33+
id: 'lead_review',
34+
type: 'approval',
35+
label: 'Lead Review',
36+
config: {
37+
// The seeded admin holds the org-membership `owner` tier, so the demo
38+
// routes stage 1 to them without hard-coding a user id.
39+
approvers: [{ type: 'org_membership_level', value: 'owner' }],
40+
behavior: 'first_response',
41+
lockRecord: true,
42+
// The lead hands the co-signers to the flow with their decision.
43+
decisionOutputs: ['next_reviewers'],
44+
},
45+
},
46+
{
47+
id: 'co_sign',
48+
type: 'approval',
49+
label: 'Co-sign',
50+
config: {
51+
// Resolved at node entry from the lead's decision outputs (#3447 P2).
52+
approvers: [{ type: 'expression', value: 'vars.lead_review.next_reviewers' }],
53+
behavior: 'unanimous',
54+
lockRecord: true,
55+
onEmptyApprovers: 'fail',
56+
},
57+
},
58+
{ id: 'approved', type: 'end', label: 'Approved' },
59+
{ id: 'rejected', type: 'end', label: 'Rejected' },
60+
],
61+
edges: [
62+
{ id: 'e1', source: 'start', target: 'lead_review' },
63+
{ id: 'e2', source: 'lead_review', target: 'co_sign', label: 'approve' },
64+
{ id: 'e3', source: 'lead_review', target: 'rejected', label: 'reject' },
65+
{ id: 'e4', source: 'co_sign', target: 'approved', label: 'approve' },
66+
{ id: 'e5', source: 'co_sign', target: 'rejected', label: 'reject' },
67+
],
68+
});

examples/app-showcase/src/automation/flows/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { defineFlow } from '@objectstack/spec';
4+
import { DynamicApprovalFlow } from './dynamic-approval.flow';
45

56
/**
67
* Task Completed → Notify — an autolaunched, record-triggered flow that fires
@@ -1620,4 +1621,6 @@ export const allFlows = [
16201621
ResilientSyncFlow,
16211622
ProjectEscalationFlow,
16221623
InboundTaskWebhookFlow,
1624+
// #3447 P2 dogfood: expression approvers + decision outputs, end to end.
1625+
DynamicApprovalFlow,
16231626
];

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,21 @@ describe('ApprovalService (node era)', () => {
441441
}, SYS)).rejects.toThrow(/VALIDATION_FAILED.*reserved/s);
442442
});
443443

444+
it('decision outputs: declared keys surface on the request row as decision_outputs (#3447 P2 UI)', async () => {
445+
// The decision UI renders one input per declared key — the keys ride the
446+
// request read (per-request; the static action params can't carry them).
447+
const req = await svc.openNodeRequest(
448+
openInput(['u9'], {}, { decisionOutputs: ['next_reviewers', 'note'] }), CTX,
449+
) as any;
450+
expect(req.decision_outputs).toEqual(['next_reviewers', 'note']);
451+
const listed = await svc.listRequests({ status: 'pending' }, SYS);
452+
expect((listed[0] as any).decision_outputs).toEqual(['next_reviewers', 'note']);
453+
// A node declaring none omits the field entirely.
454+
engine._tables['sys_approval_request'] = [];
455+
const plain = await svc.openNodeRequest(openInput(['u9']), CTX) as any;
456+
expect(plain.decision_outputs).toBeUndefined();
457+
});
458+
444459
it('decision outputs: accepted keys return from decideNode and snapshot as __decisionOutputs', async () => {
445460
const req = await svc.openNodeRequest(
446461
openInput(['u9'], {}, { decisionOutputs: ['next_reviewers', 'note'] }), CTX,

packages/plugins/plugin-approvals/src/approval-service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,13 @@ function rowFromRequest(row: any): ApprovalRequestRow {
229229
sla_due_at: slaDueAt(row.created_at, cfg),
230230
// ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
231231
round: typeof cfg?.__round === 'number' ? cfg.__round : undefined,
232+
// #3447 P2: the node's author-declared decision-output keys, surfaced so a
233+
// decision UI can render input fields for them and POST `outputs` on
234+
// approve/reject. Per-request (each node declares its own), which is why
235+
// this rides the row instead of the static action params.
236+
decision_outputs: Array.isArray(cfg?.decisionOutputs) && cfg.decisionOutputs.length
237+
? cfg.decisionOutputs.map(String)
238+
: undefined,
232239
} as any;
233240
}
234241

packages/spec/src/contracts/approval-service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export interface ApprovalRequestRow {
4848
/** ADR-0019 correlation: the suspended flow run this request belongs to. */
4949
flow_run_id?: string;
5050
flow_node_id?: string;
51+
/**
52+
* #3447 P2: the node's author-declared decision-output keys
53+
* (`config.decisionOutputs`), surfaced from the config snapshot so a
54+
* decision UI can render one input per key and POST `outputs` with the
55+
* decision. Absent when the node declares none.
56+
*/
57+
decision_outputs?: string[];
5158
completed_at?: string;
5259
created_at?: string;
5360
updated_at?: string;

0 commit comments

Comments
 (0)