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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/decision-outputs-surface-3447.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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' },
],
});
3 changes: 3 additions & 0 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -1620,4 +1621,6 @@ export const allFlows = [
ResilientSyncFlow,
ProjectEscalationFlow,
InboundTaskWebhookFlow,
// #3447 P2 dogfood: expression approvers + decision outputs, end to end.
DynamicApprovalFlow,
];
15 changes: 15 additions & 0 deletions packages/plugins/plugin-approvals/src/approval-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions packages/plugins/plugin-approvals/src/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
7 changes: 7 additions & 0 deletions packages/spec/src/contracts/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down