Skip to content

Commit 4b659d0

Browse files
authored
feat(approvals): quorum + per-group sign-off (会签) + decision attachments (#3268)
Phase 1 of gap #5 (#3266): behavior += quorum (M-of-N) and per_group (one-from-each-group 会签) with minApprovals threshold and approver group labels, tallied against an open-time snapshot (OOO-substituted); a single rejection stays a veto and thresholds clamp so mis-config can never deadlock. sys_approval_action gains a Field.file attachments column threaded through decide/comment, the REST routes, and the client SDK. Showcase ExpenseSignoffFlow demonstrates manager+finance per_group. True parallel branches remain #3267; weighted/matrix governance stays cloud#861.
1 parent 46e876c commit 4b659d0

11 files changed

Lines changed: 422 additions & 75 deletions

File tree

content/docs/automation/approvals.mdx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,35 @@ export const opportunityApproval: Flow = {
117117
For multi-step approvals, chain multiple `approval` nodes. For parallel
118118
approvals, see the aggregating-node pattern in [Flow Metadata](/docs/automation/flows).
119119

120+
### Combining multiple approvers (#3266)
121+
122+
When a node has several approvers, `behavior` decides what "approved" means —
123+
one node, no parallel branches needed:
124+
125+
| `behavior` | Advances when… | Use for |
126+
|:---|:---|:---|
127+
| `first_response` (default) | any one approves | single reviewer / "any manager" |
128+
| `unanimous` | every resolved approver approves | small fixed panels |
129+
| `quorum` | `minApprovals` of N approve (M-of-N) | "2 of 3 directors" |
130+
| `per_group` | each `group` reaches `minApprovals` (default 1) | "legal **and** finance" sign-off (会签) |
131+
132+
```ts
133+
// One legal AND one finance approver must sign off; either rejection vetoes.
134+
{ type: 'approval', config: {
135+
approvers: [
136+
{ type: 'position', value: 'legal_counsel', group: 'legal' },
137+
{ type: 'position', value: 'controller', group: 'finance' },
138+
],
139+
behavior: 'per_group', // or 'quorum' with minApprovals: 2
140+
} }
141+
```
142+
143+
A single **rejection** always finalizes the node as `rejected` (one veto), in
144+
every mode. `minApprovals` is clamped to the resolvable approver count / group
145+
size, so it can never deadlock. A decision may also carry `attachments` (file
146+
references) recorded on its audit row — e.g. a signed contract. Weighted voting
147+
and approval-matrix governance are enterprise, not here.
148+
120149
## The full lifecycle — submit to field change
121150

122151
What actually happens between "the flow hits the approval node" and "the record

content/docs/references/automation/approval.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const result = ApprovalDecision.parse(data);
5656
| :--- | :--- | :--- | :--- |
5757
| **type** | `Enum<'user' \| 'org_membership_level' \| 'role' \| 'position' \| 'team' \| 'department' \| 'manager' \| 'field' \| 'queue'>` || |
5858
| **value** | `string` | optional | User id / membership tier / position / team / department / field / queue — per `type` |
59+
| **group** | `string` | optional | Group label for per_group sign-off (e.g. "legal", "finance") |
5960

6061

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

6768
| Property | Type | Required | Description |
6869
| :--- | :--- | :--- | :--- |
69-
| **approvers** | `{ type: Enum<'user' \| 'org_membership_level' \| 'role' \| 'position' \| 'team' \| 'department' \| 'manager' \| 'field' \| 'queue'>; value?: string }[]` || Allowed approvers for this node |
70-
| **behavior** | `Enum<'first_response' \| 'unanimous'>` || How to combine multiple approvers |
70+
| **approvers** | `{ type: Enum<'user' \| 'org_membership_level' \| 'role' \| 'position' \| 'team' \| 'department' \| 'manager' \| 'field' \| 'queue'>; value?: string; group?: string }[]` || Allowed approvers for this node |
71+
| **behavior** | `Enum<'first_response' \| 'unanimous' \| 'quorum' \| 'per_group'>` || How to combine multiple approvers |
72+
| **minApprovals** | `integer` | optional | Approvals required — total (quorum) or per group (per_group). Default 1 |
7173
| **lockRecord** | `boolean` || Lock the record from editing while pending |
7274
| **approvalStatusField** | `string` | optional | Business-object field to mirror request status onto |
7375
| **escalation** | `{ enabled: boolean; timeoutHours: number; action: Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>; escalateTo?: string; … }` | optional | Per-node SLA escalation |

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,8 +1379,60 @@ export const InquiryPurgeFlow = defineFlow({
13791379
],
13801380
});
13811381

1382+
/**
1383+
* Expense Sign-off (#3266) — a `per_group` approval demonstrating 会签: a
1384+
* MANAGER **and** a FINANCE/audit approver must EACH sign off (one from each
1385+
* group) before a submitted expense report is approved; either rejection
1386+
* vetoes. Where {@link BudgetApprovalFlow} chains position steps in series,
1387+
* this gates a SINGLE node on two groups at once — the node-internal parallel
1388+
* pattern (钉钉/Salesforce style) that needs no parallel branches. Set
1389+
* `minApprovals` > 1 for "two from each group"; use `behavior: 'quorum'` +
1390+
* `minApprovals` for M-of-N collective sign-off.
1391+
*/
1392+
export const ExpenseSignoffFlow = defineFlow({
1393+
name: 'showcase_expense_signoff',
1394+
label: 'Expense Report Sign-off',
1395+
description: 'Manager + finance per-group sign-off (会签) on a submitted expense report (#3266).',
1396+
type: 'autolaunched',
1397+
status: 'active',
1398+
nodes: [
1399+
{
1400+
id: 'start',
1401+
type: 'start',
1402+
label: 'On Submitted',
1403+
config: {
1404+
objectName: 'showcase_expense_report',
1405+
triggerType: 'record-after-update',
1406+
condition: 'status == "submitted" && previous.status != "submitted"',
1407+
},
1408+
},
1409+
{
1410+
id: 'dual_signoff',
1411+
type: 'approval',
1412+
label: 'Manager + Finance Sign-off',
1413+
config: {
1414+
approvers: [
1415+
{ type: 'position', value: 'manager', group: 'manager' },
1416+
{ type: 'position', value: 'auditor', group: 'finance' },
1417+
],
1418+
behavior: 'per_group',
1419+
minApprovals: 1,
1420+
lockRecord: true,
1421+
},
1422+
},
1423+
{ id: 'approved', type: 'end', label: 'Approved' },
1424+
{ id: 'rejected', type: 'end', label: 'Rejected' },
1425+
],
1426+
edges: [
1427+
{ id: 'e1', source: 'start', target: 'dual_signoff' },
1428+
{ id: 'e2', source: 'dual_signoff', target: 'approved', label: 'approve' },
1429+
{ id: 'e3', source: 'dual_signoff', target: 'rejected', label: 'reject' },
1430+
],
1431+
});
1432+
13821433
export const allFlows = [
13831434
TaskCompletedFlow,
1435+
ExpenseSignoffFlow,
13841436
ReassignWizardFlow,
13851437
InquiryPurgeFlow,
13861438
BudgetApprovalFlow,

packages/client/src/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2493,11 +2493,11 @@ export class ObjectStackClient {
24932493
* Record an approve decision on a request. Finalises the request when the
24942494
* node's behaviour is satisfied and resumes the owning flow run.
24952495
*/
2496-
approve: async (requestId: string, decision?: { actorId?: string; comment?: string }): Promise<ApprovalDecisionResult> => {
2496+
approve: async (requestId: string, decision?: { actorId?: string; comment?: string; attachments?: string[] }): Promise<ApprovalDecisionResult> => {
24972497
const route = this.getRoute('approvals');
24982498
const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/approve`, {
24992499
method: 'POST',
2500-
body: JSON.stringify({ actorId: decision?.actorId, comment: decision?.comment })
2500+
body: JSON.stringify({ actorId: decision?.actorId, comment: decision?.comment, attachments: decision?.attachments })
25012501
});
25022502
return this.unwrapResponse<ApprovalDecisionResult>(res);
25032503
},
@@ -2506,11 +2506,11 @@ export class ObjectStackClient {
25062506
* Record a reject decision on a request. Resumes the owning flow run down
25072507
* the `reject` edge.
25082508
*/
2509-
reject: async (requestId: string, decision?: { actorId?: string; comment?: string }): Promise<ApprovalDecisionResult> => {
2509+
reject: async (requestId: string, decision?: { actorId?: string; comment?: string; attachments?: string[] }): Promise<ApprovalDecisionResult> => {
25102510
const route = this.getRoute('approvals');
25112511
const res = await this.fetch(`${this.baseUrl}${route}/requests/${encodeURIComponent(requestId)}/reject`, {
25122512
method: 'POST',
2513-
body: JSON.stringify({ actorId: decision?.actorId, comment: decision?.comment })
2513+
body: JSON.stringify({ actorId: decision?.actorId, comment: decision?.comment, attachments: decision?.attachments })
25142514
});
25152515
return this.unwrapResponse<ApprovalDecisionResult>(res);
25162516
},

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,3 +1287,106 @@ describe('sys_approval_delegation write guard (#1322)', () => {
12871287
)).rejects.toThrow(/FORBIDDEN/);
12881288
});
12891289
});
1290+
1291+
// ── Quorum & per-group sign-off (#3266) ───────────────────────────────
1292+
//
1293+
// quorum = M-of-N collective sign-off; per_group = one (or minApprovals) from
1294+
// EACH group (会签). A single rejection is always a veto. Group membership is
1295+
// snapshotted at open, so OOO-substituted approvers count for their group.
1296+
describe('ApprovalService — quorum & per_group (#3266)', () => {
1297+
let engine: ReturnType<typeof makeFakeEngine>;
1298+
let svc: ApprovalService;
1299+
const base = new Date('2026-08-01T10:00:00Z').getTime();
1300+
1301+
beforeEach(() => {
1302+
engine = makeFakeEngine();
1303+
let n = 0;
1304+
svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(base + (n++) * 1000) } });
1305+
});
1306+
1307+
// Build an openNodeRequest input with explicit approver specs + behavior.
1308+
const cfg = (approvers: any[], behavior: string, extra: Record<string, any> = {}) => ({
1309+
...openInput([]),
1310+
config: { approvers, behavior, lockRecord: true, ...extra },
1311+
});
1312+
const U = (v: string, group?: string) => (group ? { type: 'user', value: v, group } : { type: 'user', value: v });
1313+
1314+
it('quorum: holds until minApprovals reached, then finalizes', async () => {
1315+
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
1316+
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
1317+
expect(a.finalized).toBe(false);
1318+
expect(a.request.status).toBe('pending');
1319+
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
1320+
expect(b.finalized).toBe(true);
1321+
expect(b.request.status).toBe('approved');
1322+
});
1323+
1324+
it('quorum: minApprovals clamps to the approver count (no deadlock)', async () => {
1325+
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2')], 'quorum', { minApprovals: 5 }), CTX);
1326+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
1327+
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u2' }, SYS);
1328+
expect(b.finalized).toBe(true);
1329+
});
1330+
1331+
it('quorum: any reject is a veto', async () => {
1332+
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
1333+
const r = await svc.decideNode(req.id, { decision: 'reject', actorId: 'u1' }, SYS);
1334+
expect(r.finalized).toBe(true);
1335+
expect(r.request.status).toBe('rejected');
1336+
});
1337+
1338+
it('per_group: advances only when EACH group approves', async () => {
1339+
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
1340+
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
1341+
expect(a.finalized).toBe(false); // finance still pending
1342+
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f1' }, SYS);
1343+
expect(b.finalized).toBe(true);
1344+
expect(b.request.status).toBe('approved');
1345+
});
1346+
1347+
it('per_group: two approvals in ONE group do not satisfy another group', async () => {
1348+
const req = await svc.openNodeRequest(
1349+
cfg([U('l1', 'legal'), U('l2', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
1350+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
1351+
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'l2' }, SYS);
1352+
expect(b.finalized).toBe(false); // finance still missing
1353+
});
1354+
1355+
it('per_group: minApprovals=2 needs two from each group', async () => {
1356+
const req = await svc.openNodeRequest(cfg(
1357+
[U('l1', 'legal'), U('l2', 'legal'), U('f1', 'finance'), U('f2', 'finance')],
1358+
'per_group', { minApprovals: 2 }), CTX);
1359+
for (const u of ['l1', 'f1', 'l2']) {
1360+
const r = await svc.decideNode(req.id, { decision: 'approve', actorId: u }, SYS);
1361+
expect(r.finalized).toBe(false);
1362+
}
1363+
const done = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f2' }, SYS);
1364+
expect(done.finalized).toBe(true);
1365+
});
1366+
1367+
it('per_group: reject is a veto', async () => {
1368+
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
1369+
const r = await svc.decideNode(req.id, { decision: 'reject', actorId: 'l1' }, SYS);
1370+
expect(r.request.status).toBe('rejected');
1371+
});
1372+
1373+
it('per_group: an OOO-substituted member still counts for their group', async () => {
1374+
engine._tables['sys_approval_delegation'] = [
1375+
{ id: 'd', delegator_id: 'l1', delegate_id: 'lb', organization_id: 't1', valid_from: null, valid_until: null, reason: 'leave' },
1376+
];
1377+
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
1378+
expect(req.pending_approvers).toContain('lb');
1379+
expect(req.pending_approvers).not.toContain('l1');
1380+
const a = await svc.decideNode(req.id, { decision: 'approve', actorId: 'lb' }, SYS); // delegate covers legal
1381+
expect(a.finalized).toBe(false);
1382+
const b = await svc.decideNode(req.id, { decision: 'approve', actorId: 'f1' }, SYS);
1383+
expect(b.finalized).toBe(true);
1384+
});
1385+
1386+
it('records decision attachments on the audit row', async () => {
1387+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
1388+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_1', 'file_2'] }, SYS);
1389+
const act = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
1390+
expect(act.attachments).toEqual(['file_1', 'file_2']);
1391+
});
1392+
});

0 commit comments

Comments
 (0)