When to Use
Use this skill when a human or a bot can move assets out of custody and you need the release decision to require more than one person, in a form an auditor can later read. The engine enforces four distinct controls that are easy to confuse with each other:
- Maker-checker — the initiator of a proposal may never approve it.
- A role-conflict matrix — no single identity holds a role combination that would let one person create and bless the same work, screened at registration.
- Payload binding — an approval is consent to a specific destination, asset, amount and approval threshold, not to a proposal id.
- A tamper-evident audit chain — every registration, proposal, approval, rejection and execution is a SHA-256 link over its predecessor.
It exists because "two people approved it" is worth nothing if one of them proposed it, if the second was the same person under a second role, if the destination address changed after both approved, or if the only record is a chat message.
On the compliance framing, be precise. SOC 2 is an attestation engagement
against the AICPA Trust Services Criteria, not a certification and not a rule
book of thresholds. The TSC ask for segregation of duties in general terms —
CC5.1's point of focus "Addresses Segregation of Duties" and CC6.3's requirement
that access be granted "giving consideration to the concepts of least privilege
and segregation of duties". No criterion prescribes a dollar threshold, an
approval count, or a hash algorithm. Every number in the config is your firm's
policy, which you must set and defend. See references/standards.md.
When NOT to Use
- As the thing that actually stops a transfer. This is an off-chain gate in your own process. An attacker who controls this code skips it. The authoritative enforcer must be the custodian's policy engine, the HSM quorum policy, or the on-chain multisig threshold. This layer runs first and produces the record.
- As a signature verifier.
ApprovalRecord.signature_hashis an unkeyed SHA-256 chain link. It makes a later edit detectable; it does not prove the named approver approved anything. Authenticity is the identity layer's job — the engine trusts whateverapprover_idits caller passes. - As the M-of-N quorum and timelock layer. This engine has no timelock, no
distinct-role quorum requirement, and no abort window. Use
multi-signature-approval-for-large-transfersfor that; the two compose. - As a velocity, whitelist, or anomaly control. Nothing here caps how many
approved transfers leave per hour or scores a destination as unusual. See
withdrawal-velocity-limits-and-anomaly-detectionandexchange-withdrawal-whitelist-enforcement. - As the system of record. Proposals, users and the chain live in process
memory. It is tamper-evident, not immutable: anything that can rewrite this
process can recompute the whole chain. Persist entries and publish
chain_head_hashto append-only storage. - Across processes as written. State is guarded by an in-process lock, so one
engine instance is thread-safe. Two workers each holding their own engine will
each see the same proposal as
APPROVEDand can release the same transfer twice.
Prerequisites
- A registered roster.
register_user(UserIdentity(user_id, username, department, roles))for every identity, sourced from your identity provider. Roles are snapshotted into afrozensetat registration; mutating the caller's set afterwards does not change the engine's view. Re-registering an existinguser_idraises unlessreplace=True. - A role-conflict matrix.
DEFAULT_INCOMPATIBLE_ROLE_PAIRSforbidsSECURITY_ADMINwithINITIATOR,APPROVERorAUDITOR, andAUDITORwithINITIATORorAPPROVER. It deliberately permitsINITIATOR+APPROVER, because many firms staff one person as maker on one workflow and checker on another; the per-proposal self-approval block still holds. PassSTRICT_INCOMPATIBLE_ROLE_PAIRSif your policy forbids that combination outright. - Tier policy in the config.
large_transfer_threshold_usd(default50000.0),approvals_below_threshold(default 1),approvals_at_or_above_threshold(default 2). These defaults are illustrative, not regulatory. The constructor rejects an approval count below 1, a negative or non-finite threshold, and a departmental minimum that would make a tier unreachable. - Optional departmental independence.
min_distinct_approver_departments(default 1, off) andforbid_approver_from_initiator_department(defaultFalse). Both are off by default becausedepartmentis free text your firm defines. - A trusted clock. Pass
clock=for reproducible audit chains; it defaults totime.time. - Append-only storage for the persisted chain, and a serialisation point around the approve-then-submit sequence if more than one worker can execute.
Workflow
- Register Identities Before Anything Else, and Treat a Role Grant as an
Event:
register_userscreens the role-conflict matrix before storing, so a rejected registration leaves no user and does not advance the chain. It raises on a duplicateuser_idrather than silently overwriting, because a silent overwrite is how a checker quietly becomes a maker. - Propose (Maker Step), and Let Malformed Notionals Raise:
propose_transferrequires theINITIATORrole and validates the notional first. NaN, Inf, zero and negative amounts raiseSoDConflictError. This matters more than it looks:float('nan') >= thresholdisFalse, so an unvalidated NaN would be classified as a small transfer and routed to the lower approval requirement. The threshold boundary is inclusive — exactlylarge_transfer_threshold_usdis a large transfer. - Treat a Retry as a Retry, Not a New Proposal: Re-submitting an identical
proposal returns the existing one with its approvals intact. Re-using a
proposal_idwith different content raisesDUPLICATE_PROPOSAL_IDrather than replacing a proposal that may already carry approvals. - Approve (Checker Step) — and Note the Order of the Checks: The
self-approval block runs first, before the role check, so an initiator who
has since been granted
APPROVERis refused on their own proposal withSELF_APPROVAL_ATTEMPTrather than a misleading role error. Then: terminal status,APPROVERrole, departmental independence, duplicate approval. - Bind Each Approval to the Payload That Was Reviewed:
compute_proposal_digesthashes the proposal id, initiator, destination, asset, amount andrequired_approvalsunder a domain separator, each field length-prefixed so no field-boundary shuffle collides. The approval stores that digest.required_approvalsis inside the digest deliberately: lowering the threshold after approvals are in is as much an escalation as changing the destination. - Re-derive the Status Before You Act on It:
refresh_status()recounts only the approvals still bound to the current payload. If a field changed after approval, the count drops and the status falls back toPENDING, with an error-level log. Revert the field and the original approvals count again — the digest is a function of content, not a one-way latch. - Close the Loop Exactly Once at Submission:
mark_executed(proposal_id, executor_id)re-derives the status first, refuses anything not currentlyAPPROVED, and refuses a second call. Call it when the transfer is submitted, not when it confirms: a crash in between must not be resolved by releasing the transfer again.reject_transferis the terminal negative path and stays open to a checker who already approved — withdrawing consent must always be available. - Verify and Publish the Chain:
verify_audit_chain()returns(ok, reason)and names the offending sequence number. Publishchain_head_hashto append-only storage on a cadence.
Full procedure: see
references/workflows.md. Standards reference: seereferences/standards.md. Printable pre-flight checklist: seeassets/checklist.md.
Common Pitfalls
- Believing a Proposal Object Is Frozen Once Approved:
CustodyTransferProposalis a plain mutable dataclass. Nothing stops calling code from rewritingdestination_addressafter two people approved — and in v1.0.0 the status stayedAPPROVEDand the two approvals still counted. That is the Bybit shape of failure: a legitimate quorum, a different payload. Always readrefresh_status()(ormark_executed, which calls it) rather thanproposal.statuswhen deciding to release funds. - Calling an Unkeyed Hash a Signature:
signature_hashis a chain link, not a signature and not evidence of identity. Anyone who can callapprove_transfercan produce a valid-looking one under anyapprover_id. Presenting it to an auditor as proof that a named person approved is a claim the artefact does not support; what it supports is that the record has not been edited since. - Treating a Role Set as Trusted After Registration: v1.0.0 stored the
caller's
setby reference, souser.roles.add(APPROVER)silently granted approval rights inside the engine and bypassed the registration-time conflict screen entirely. The roles are now snapshotted. If you keep your own mutable identity objects, they are a cache, not the authority. - Reading a Role-Conflict Matrix as Maker-Checker, or Vice Versa: They are different controls. The matrix asks "may one person hold both these roles at all?"; maker-checker asks "did the person who proposed this approve it?" A firm that only implements the matrix still lets two people on the same desk rubber-stamp each other. A firm that only implements maker-checker still lets the security admin who manages the roster approve transfers.
- Counting Approvals Instead of Counting Independent Approvals: Two
approvals from one desk is one compromised desk.
departmentexisted in v1.0.0's model and was never read by anything, which is worse than absent — it implies a control that was not running. Setmin_distinct_approver_departmentsif independence is what you actually wanted, and remember the comparison is case-insensitive but otherwise exact: "Trading" and "Trading Ops" are two departments. - Assuming a Single Approval Tier Is a Weaker Control: With
approvals_below_threshold=1, a small transfer still needs one checker who is not the maker. That is dual control. What it is not is M-of-N; if you want a quorum with a timelock and an abort window, composemulti-signature-approval-for-large-transfers. - Deploying Two Workers Against One Wallet: The engine's lock is
in-process. Two processes each see
APPROVEDand each callmark_executedsuccessfully on their own copy. Serialise execution externally or persist the proposal state. - Quoting a Threshold as a Requirement:
$50,000and "2 approvals" are this module's illustrative defaults. No source inreferences/standards.md— AICPA TSC, NIST SP 800-53, BCBS d515, or 23 NYCRR 500 — prescribes either. Record them as firm policy with a named owner.
Verification
- Propose $100,000 and confirm
required_approvals == 2; propose $49,999.99 and confirm1; propose exactly$50,000and confirm2(inclusive boundary). - Propose with
float('nan')and confirmSoDConflictErrorwithviolation_type == INVALID_PAYLOAD, and that no proposal was stored — not a single-approval proposal. - Have the maker attempt to approve their own proposal and confirm the
violation_typeisSELF_APPROVAL_ATTEMPT, notUNAUTHORIZED_ROLE, even after addingAPPROVERto the caller's own role set. - Collect both approvals, then rewrite
destination_addresson the returned object, and confirmrefresh_status()returnsPENDINGandmark_executedraisesTHRESHOLD_NOT_MET. Restore the field and confirm it returns toAPPROVED. - Lower
required_approvalson an approved proposal and confirm it also drops toPENDING— the threshold is inside the digest. - Re-submit an identical proposal and confirm the approvals already collected
survive; re-submit the same id with a different destination and confirm
DUPLICATE_PROPOSAL_IDand that the stored proposal is unchanged. - Register
SECURITY_ADMIN+APPROVERandAUDITOR+APPROVERand confirm both are rejected; confirmINITIATOR+APPROVERis accepted by default and rejected underSTRICT_INCOMPATIBLE_ROLE_PAIRS. - Call
mark_executedtwice and confirm the second raisesPROPOSAL_NOT_PENDING. - Mutate a stored
AuditEntryin place and confirmverify_audit_chain()returns(False, reason)naming the sequence number; delete one and confirm the same. - Attempt a registration that violates the matrix and confirm
chain_head_hashdid not advance. - Run
python -m unittest discover -s skills/segregation-of-duties-for-custody-operations/scriptsand confirm a 100% pass rate.
Related Skills
multi-signature-approval-for-large-transferswithdrawal-velocity-limits-and-anomaly-detectionemployee-offboarding-procedure-for-custody-accessrisk-control-bypass-audit-loggingrisk-control-configuration-change-approval-workflowemergency-manual-override-access-controlexchange-withdrawal-whitelist-enforcementtest-transaction-verification-before-large-transfersregulatory-custody-requirements-by-jurisdiction