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
29 changes: 29 additions & 0 deletions content/docs/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,35 @@ export const opportunityApproval: Flow = {
For multi-step approvals, chain multiple `approval` nodes. For parallel
approvals, see the aggregating-node pattern in [Flow Metadata](/docs/automation/flows).

### Combining multiple approvers (#3266)

When a node has several approvers, `behavior` decides what "approved" means —
one node, no parallel branches needed:

| `behavior` | Advances when… | Use for |
|:---|:---|:---|
| `first_response` (default) | any one approves | single reviewer / "any manager" |
| `unanimous` | every resolved approver approves | small fixed panels |
| `quorum` | `minApprovals` of N approve (M-of-N) | "2 of 3 directors" |
| `per_group` | each `group` reaches `minApprovals` (default 1) | "legal **and** finance" sign-off (会签) |

```ts
// One legal AND one finance approver must sign off; either rejection vetoes.
{ type: 'approval', config: {
approvers: [
{ type: 'position', value: 'legal_counsel', group: 'legal' },
{ type: 'position', value: 'controller', group: 'finance' },
],
behavior: 'per_group', // or 'quorum' with minApprovals: 2
} }
```

A single **rejection** always finalizes the node as `rejected` (one veto), in
every mode. `minApprovals` is clamped to the resolvable approver count / group
size, so it can never deadlock. A decision may also carry `attachments` (file
references) recorded on its audit row — e.g. a signed contract. Weighted voting
and approval-matrix governance are enterprise, not here.

## The full lifecycle — submit to field change

What actually happens between "the flow hits the approval node" and "the record
Expand Down
6 changes: 4 additions & 2 deletions content/docs/references/automation/approval.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const result = ApprovalDecision.parse(data);
| :--- | :--- | :--- | :--- |
| **type** | `Enum<'user' \| 'org_membership_level' \| 'role' \| 'position' \| 'team' \| 'department' \| 'manager' \| 'field' \| 'queue'>` | ✅ | |
| **value** | `string` | optional | User id / membership tier / position / team / department / field / queue — per `type` |
| **group** | `string` | optional | Group label for per_group sign-off (e.g. "legal", "finance") |


---
Expand All @@ -66,8 +67,9 @@ const result = ApprovalDecision.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **approvers** | `{ type: Enum<'user' \| 'org_membership_level' \| 'role' \| 'position' \| 'team' \| 'department' \| 'manager' \| 'field' \| 'queue'>; value?: string }[]` | ✅ | Allowed approvers for this node |
| **behavior** | `Enum<'first_response' \| 'unanimous'>` | ✅ | How to combine multiple approvers |
| **approvers** | `{ type: Enum<'user' \| 'org_membership_level' \| 'role' \| 'position' \| 'team' \| 'department' \| 'manager' \| 'field' \| 'queue'>; value?: string; group?: string }[]` | ✅ | Allowed approvers for this node |
| **behavior** | `Enum<'first_response' \| 'unanimous' \| 'quorum' \| 'per_group'>` | ✅ | How to combine multiple approvers |
| **minApprovals** | `integer` | optional | Approvals required — total (quorum) or per group (per_group). Default 1 |
| **lockRecord** | `boolean` | ✅ | Lock the record from editing while pending |
| **approvalStatusField** | `string` | optional | Business-object field to mirror request status onto |
| **escalation** | `{ enabled: boolean; timeoutHours: number; action: Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>; escalateTo?: string; … }` | optional | Per-node SLA escalation |
Expand Down
52 changes: 52 additions & 0 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1379,8 +1379,60 @@ export const InquiryPurgeFlow = defineFlow({
],
});

/**
* Expense Sign-off (#3266) — a `per_group` approval demonstrating 会签: a
* MANAGER **and** a FINANCE/audit approver must EACH sign off (one from each
* group) before a submitted expense report is approved; either rejection
* vetoes. Where {@link BudgetApprovalFlow} chains position steps in series,
* this gates a SINGLE node on two groups at once — the node-internal parallel
* pattern (钉钉/Salesforce style) that needs no parallel branches. Set
* `minApprovals` > 1 for "two from each group"; use `behavior: 'quorum'` +
* `minApprovals` for M-of-N collective sign-off.
*/
export const ExpenseSignoffFlow = defineFlow({
name: 'showcase_expense_signoff',
label: 'Expense Report Sign-off',
description: 'Manager + finance per-group sign-off (会签) on a submitted expense report (#3266).',
type: 'autolaunched',
status: 'active',
nodes: [
{
id: 'start',
type: 'start',
label: 'On Submitted',
config: {
objectName: 'showcase_expense_report',
triggerType: 'record-after-update',
condition: 'status == "submitted" && previous.status != "submitted"',
},
},
{
id: 'dual_signoff',
type: 'approval',
label: 'Manager + Finance Sign-off',
config: {
approvers: [
{ type: 'position', value: 'manager', group: 'manager' },
{ type: 'position', value: 'auditor', group: 'finance' },
],
behavior: 'per_group',
minApprovals: 1,
lockRecord: true,
},
},
{ id: 'approved', type: 'end', label: 'Approved' },
{ id: 'rejected', type: 'end', label: 'Rejected' },
],
edges: [
{ id: 'e1', source: 'start', target: 'dual_signoff' },
{ id: 'e2', source: 'dual_signoff', target: 'approved', label: 'approve' },
{ id: 'e3', source: 'dual_signoff', target: 'rejected', label: 'reject' },
],
});

export const allFlows = [
TaskCompletedFlow,
ExpenseSignoffFlow,
ReassignWizardFlow,
InquiryPurgeFlow,
BudgetApprovalFlow,
Expand Down
8 changes: 4 additions & 4 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2493,11 +2493,11 @@ export class ObjectStackClient {
* Record an approve decision on a request. Finalises the request when the
* node's behaviour is satisfied and resumes the owning flow run.
*/
approve: async (requestId: string, decision?: { actorId?: string; comment?: string }): Promise<ApprovalDecisionResult> => {
approve: async (requestId: string, decision?: { actorId?: string; comment?: string; attachments?: string[] }): Promise<ApprovalDecisionResult> => {
const route = this.getRoute('approvals');
const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/approve`, {
method: 'POST',
body: JSON.stringify({ actorId: decision?.actorId, comment: decision?.comment })
body: JSON.stringify({ actorId: decision?.actorId, comment: decision?.comment, attachments: decision?.attachments })
});
return this.unwrapResponse<ApprovalDecisionResult>(res);
},
Expand All @@ -2506,11 +2506,11 @@ export class ObjectStackClient {
* Record a reject decision on a request. Resumes the owning flow run down
* the `reject` edge.
*/
reject: async (requestId: string, decision?: { actorId?: string; comment?: string }): Promise<ApprovalDecisionResult> => {
reject: async (requestId: string, decision?: { actorId?: string; comment?: string; attachments?: string[] }): Promise<ApprovalDecisionResult> => {
const route = this.getRoute('approvals');
const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/reject`, {
method: 'POST',
body: JSON.stringify({ actorId: decision?.actorId, comment: decision?.comment })
body: JSON.stringify({ actorId: decision?.actorId, comment: decision?.comment, attachments: decision?.attachments })
});
return this.unwrapResponse<ApprovalDecisionResult>(res);
},
Expand Down
103 changes: 103 additions & 0 deletions packages/plugins/plugin-approvals/src/approval-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1287,3 +1287,106 @@ describe('sys_approval_delegation write guard (#1322)', () => {
)).rejects.toThrow(/FORBIDDEN/);
});
});

// ── Quorum & per-group sign-off (#3266) ───────────────────────────────
//
// quorum = M-of-N collective sign-off; per_group = one (or minApprovals) from
// EACH group (会签). A single rejection is always a veto. Group membership is
// snapshotted at open, so OOO-substituted approvers count for their group.
describe('ApprovalService — quorum & per_group (#3266)', () => {
let engine: ReturnType<typeof makeFakeEngine>;
let svc: ApprovalService;
const base = new Date('2026-08-01T10:00:00Z').getTime();

beforeEach(() => {
engine = makeFakeEngine();
let n = 0;
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
});

// Build an openNodeRequest input with explicit approver specs + behavior.
const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
...openInput([]),
config: { approvers, behavior, lockRecord: true, ...extra },
});
const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });

it('quorum: holds until minApprovals reached, then finalizes', async () => {
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
expect(a.finalized).toBe(false);
expect(a.request.status).toBe('pending');
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
expect(b.finalized).toBe(true);
expect(b.request.status).toBe('approved');
});

it('quorum: minApprovals clamps to the approver count (no deadlock)', async () => {
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2')], 'quorum', { minApprovals: 5 }), CTX);
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
expect(b.finalized).toBe(true);
});

it('quorum: any reject is a veto', async () => {
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
const r = await svc.decideNode(req.id, { decision: 'reject', actorId: 'u1' }, SYS);
expect(r.finalized).toBe(true);
expect(r.request.status).toBe('rejected');
});

it('per_group: advances only when EACH group approves', async () => {
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
expect(a.finalized).toBe(false); // finance still pending
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f1' }, SYS);
expect(b.finalized).toBe(true);
expect(b.request.status).toBe('approved');
});

it('per_group: two approvals in ONE group do not satisfy another group', async () => {
const req = await svc.openNodeRequest(
cfg([U('l1', 'legal'), U('l2', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'l2' }, SYS);
expect(b.finalized).toBe(false); // finance still missing
});

it('per_group: minApprovals=2 needs two from each group', async () => {
const req = await svc.openNodeRequest(cfg(
[U('l1', 'legal'), U('l2', 'legal'), U('f1', 'finance'), U('f2', 'finance')],
'per_group', { minApprovals: 2 }), CTX);
for (const u of ['l1', 'f1', 'l2']) {
const r = await svc.decideNode(req.id, { decision: 'approve', actorId: u }, SYS);
expect(r.finalized).toBe(false);
}
const done = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f2' }, SYS);
expect(done.finalized).toBe(true);
});

it('per_group: reject is a veto', async () => {
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
const r = await svc.decideNode(req.id, { decision: 'reject', actorId: 'l1' }, SYS);
expect(r.request.status).toBe('rejected');
});

it('per_group: an OOO-substituted member still counts for their group', async () => {
engine._tables['sys_approval_delegation'] = [
{ id: 'd', delegator_id: 'l1', delegate_id: 'lb', organization_id: 't1', valid_from: null, valid_until: null, reason: 'leave' },
];
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
expect(req.pending_approvers).toContain('lb');
expect(req.pending_approvers).not.toContain('l1');
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'lb' }, SYS); // delegate covers legal
expect(a.finalized).toBe(false);
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f1' }, SYS);
expect(b.finalized).toBe(true);
});

it('records decision attachments on the audit row', async () => {
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_1', 'file_2'] }, SYS);
const act = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
expect(act.attachments).toEqual(['file_1', 'file_2']);
});
});
Loading