Contract Approval Pipeline
Every contract goes through Legal before execution. Large contracts or long-term agreements add Finance and Board levels. Either legal approver can clear the review. For board-level contracts, both CEO and board chair must sign.
// The counterparty's signer comes from the contract data
engine.registerResolver('counterparty-signatory', (_submittedBy, data) => String(data.counterpartySignerId));
const totalValue = (min: number) => ({ field: 'totalValue', operator: '>', value: min });
await engine.defineTemplate({
name: 'contract-signing',
documentType: 'contract',
levels: [
{ level: 1, name: 'Department head', approvers: [{ type: 'role', role: 'department-head' }], mode: 'any' },
// Legal review is part of every chain; either counsel can clear it
{
level: 2,
name: 'Legal',
approvers: [{ type: 'role', role: 'legal-counsel' }, { type: 'role', role: 'legal-reviewer' }],
mode: 'any',
},
],
conditions: [
// Finance for high-value or long-duration contracts
{
when: { any: [totalValue(50_000), { field: 'durationMonths', operator: '>', value: 24 }] },
addLevels: [{ level: 3, name: 'CFO', approvers: [{ type: 'role', role: 'cfo' }], mode: 'any' }],
},
// Board for very high-value contracts: both must sign
{
when: totalValue(250_000),
addLevels: [
{
level: 4,
name: 'Board',
approvers: [{ type: 'role', role: 'ceo' }, { type: 'role', role: 'board-chair' }],
mode: 'all',
},
],
},
// The counterparty signs last
{
when: { field: 'requiresCounterSignature', operator: '==', value: true },
addLevels: [
{ level: 5, name: 'Counterparty', approvers: [{ type: 'dynamic', resolver: 'counterparty-signatory' }], mode: 'any' },
],
},
],
}); Contract workflows have unusual edge cases.
Unlike expense approvals, contracts involve external parties and evolve over their lifecycle.
Amendment restarts the chain
A $45k contract is in approval. The vendor negotiates an extension to $60k. The amendment crosses the CFO threshold — it needs a new approval chain, not just a note on the old one.
// Withdraw the in-flight contract, then submit the amended one
async function submitAmendment(original: Contract, amended: Contract) {
await engine.cancel(original.instanceId, {
cancelledBy: amended.submittedBy,
reason: 'Superseded by amendment',
});
// The new instance evaluates the conditions against the amended value:
// 45k -> 60k adds the CFO level
return engine.submit({
templateName: 'contract-signing',
documentType: 'contract',
documentId: `${original.contractId}-A1`,
submittedBy: amended.submittedBy,
data: { ...amended.data, isAmendment: true, originalContractId: original.contractId },
});
}
The original instance ends as cancelled with the reason in its audit trail.
The new instance carries originalContractId in its data, and the
thresholds apply to the amended value.
External counter-signature via webhook
The counterparty (a vendor or customer) must also sign before the contract is fully executed. Their signature comes from an e-signature platform, not from inside your system.
// Receive the counter-signature from DocuSign / Adobe Sign
app.post('/webhooks/docusign', async (req, res) => {
const { envelopeId, status, signerEmail } = req.body;
if (status !== 'completed') return res.sendStatus(200);
// Your own table maps the envelope to the approval instance
const { instance_id } = (await db.query(
'SELECT instance_id FROM contract_envelopes WHERE envelope_id = $1',
[envelopeId]
)).rows[0];
// signerEmail must match the id the resolver returned: `external:${email}`
await engine.approve(instance_id, {
approverId: `external:${signerEmail}`,
comment: `Counter-signed via DocuSign envelope ${envelopeId}`,
});
res.sendStatus(200);
});
The counterparty is an approver like any other: the resolver returns an id
prefixed with external:, and the webhook approves as that id.
The audit trail records who signed externally.
Legal hold mid-approval
A contract is at the CFO level awaiting approval when legal sends a hold because of a regulatory review. The CFO should not be able to approve until the hold is lifted.
hold flag on the contract in your DB authorizationPolicy whose authorize() returns a message for approve while the hold flag is set IAuthorizationPolicy is called on approve, reject and the other level actions.
Returning a non-empty string denies the call with an ApprovalForbiddenError
carrying that message, and the instance is left unchanged.
Your next approval workflow
ships faster than the last one.
Drop it into any Node.js project. No database required for local development.
$ npm install hierarchical-approval Peer dep for Postgres: npm install pg @types/pg