Skip to content

Commit cc46574

Browse files
committed
feat(approvals): quorum + per-group sign-off (会签) + decision attachments (#3266)
Node-internal multi-approver semantics — the钉钉/Salesforce-style patterns that deliver "legal AND finance both approve" and "M-of-N" without any flow-engine change (topology-level parallel branches remain #3267). Community-core; weighted voting + approval-matrix governance stay enterprise (objectstack-ai/cloud#861). Spec (approval.zod.ts): - behavior enum += 'quorum' (M-of-N) and 'per_group' (one — or minApprovals — from EACH group), plus a `minApprovals` threshold and an optional `group` label on each approver. A single rejection is always a veto. Corrected the stale "quorum is enterprise" comment. Engine (approval-service.ts): - expandApprovers refactored to resolveApproverSpec + a group collector, so each resolved approver (OOO-substituted included) is tagged with its group; the map is snapshotted at open so the tally is stable. - decideNode: unified quorum/per_group/unanimous finalization via isApprovalSatisfied; thresholds clamp to the resolvable count / group size so a mis-set value can never deadlock. reassign transfers a slot's group so the new approver still counts. Attachments (#3266): - sys_approval_action gains a `Field.file` `attachments` column; decide/comment accept + persist file references, threaded through the contract, REST decide/comment routes, and the client approve/reject helpers. Showcase: ExpenseSignoffFlow — manager + finance per_group 会签 on a submitted expense report. Tests: 9 new (quorum boundary + clamp + veto, per_group each-group / two-in-one-group / minApprovals=2 / OOO-substituted member counts, attachments); 132 green in plugin-approvals. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ypkQikZ55oWUHUnMebwXA
1 parent 611402f commit cc46574

8 files changed

Lines changed: 373 additions & 72 deletions

File tree

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)