polycratia

Threshold approvals belong on the request, not on the button

· 8 min read

Above a configured amount, a withdrawal should not leave the system because somebody clicked approve. It leaves because enough distinct people have signed that specific request, and because those signatures still satisfy the policy at the moment a rail is actually touched. Most of the approval bugs I have found in payment and custody systems come from the opposite arrangement: the click is treated as the event that moves money, and the request record is a log line written afterwards.

That inversion is what makes both classes of failure possible. If the click sends, then a second click sends again, and a policy change tomorrow cannot invalidate a signature collected yesterday, because there is nothing left to re-measure. I have built moderation and issuance tiers for a lending marketplace, and real-time compliance queues for an onramp, and the same lesson showed up in both: the reviewer's action has to be recorded as a property of the thing under review, not as a trigger wired to a side effect.

I keep this pattern in withdrawals. It is deliberately small: a request, a state machine, an approval gate, a routing decision, and idempotent submission. The senders are not implemented, and that is on purpose: everything interesting happens before the rail.

Waiting is a property of the state machine, not of the handler#

If the gate is a check at the top of the send handler, it is one refactor away from being skipped, and every new call path has to remember it. It holds up better when approved is simply unreachable until the policy is satisfied. A request travels requested -> approved -> sending -> sent -> confirmed, and the sender only ever receives requests that are already on the far side of the gate.

python
from decimal import Decimal

from withdrawals import (
    ApprovalPolicy,
    WithdrawalRequest,
    WithdrawalState,
    approve,
)

policy = ApprovalPolicy.four_eyes(above=Decimal("1000"))

large = WithdrawalRequest(
    id="w-1042",
    amount=Decimal("5000.00"),
    currency="USDC",
    destination="0xab5801a7d398351b8be11c439e05c5b3259aec9b",
    requested_by="alice",
)

waiting = approve(large, "bob", policy)
assert waiting.state is WithdrawalState.REQUESTED

approved = approve(waiting, "carol", policy)
assert approved.state is WithdrawalState.APPROVED
assert approved.approvers == ("bob", "carol")

Bob's approval is not rejected and it is not parked somewhere else. It is recorded on the request, and the request stays in requested, because the amount cleared a rule that asks for two distinct approvers. The policy is a set of rules, each with the amount it starts at and the number of approvers it asks for: the rule with the highest threshold the amount clears is the one that applies, and one rule starts at zero so that every amount matches exactly one. No amount falls through the policy unmatched.

Note that request.approve() and approve(request, who, policy) are different calls. The first is the bare state move, useful in tests and for amounts below any threshold. The second is the one that asks the policy first. Keeping them apart means the policy-aware path is a real function with a name, not an implicit behaviour of a method that also does something else.

An approval is evidence, and evidence gets re-checked#

Every approval names who gave it, when they gave it with a timezone-aware timestamp, and the rule that was in force at the time. All of it travels with the request all the way to confirmed.

python
assert approved.approvals[0].policy == "four-eyes"

That last field is the one people leave out, and it is the one that matters most a year in. Thresholds move. A limit that asked for two approvers above one amount gets tightened after an incident or a compliance review. If the only thing you stored was a boolean, every request approved under the old rule is now indistinguishable from one approved under the new one, and you find the difference in an audit rather than in your code.

So the check happens twice: once when the approval is given, and once at the boundary, immediately before the request is handed to a rail.

python
from withdrawals import ensure_approved

ensure_approved(approved, policy)  # raises NotApproved if the signatures no longer suffice

This works because the request is an immutable value and the approvals are part of it. Transitions return a new request and you decide where to store it, so nothing can quietly mutate the evidence between the gate and the send. ensure_approved measures the request against the policy as it stands right now. A request approved yesterday under a looser rule does not slip out today.

The same click twice is not a second pair of eyes#

Two more things the approval layer refuses. The person who asked for the money cannot be one of the approvers, which raises SelfApproval. And the same approver recorded twice counts once, so a double-clicked button, a retried request, or a duplicated webhook from an approval UI does not manufacture the second signature.

That is idempotency at the human layer, and it is worth naming as such, because it has the same shape as the machine-layer problem further down the pipeline. The identity that matters is the approver, not the click. Once you see it that way, the rest of the pipeline is a question of asking, at each step, which identity is this step keyed by?

After the gate, two more identities#

They are not the same identity, and collapsing them is where money gets sent twice.

Routing is keyed by the destination. A withdrawal to an address you custody does not reach a chain: crediting it is a ledger move with no fee, no confirmations, and nothing to wait for. The decision comes back as a value with the reason behind it, and it is logged, so the two paths stay distinguishable afterwards:

python
from withdrawals import InMemoryCustody, Rail, route

custody = InMemoryCustody({"0x5c69bee701ef814a2b6a3edd4b1652cb9cc5aa6f": "acct-77"})

decision = route(request, custody)

assert decision.rail is Rail.INTERNAL
assert decision.account == "acct-77"
assert not decision.needs_confirmations

An internal route without an account and an external route with one are both refused at construction. The decision cannot be half-formed.

Submission is keyed by neither the destination nor the withdrawal id, but by a request key the client chooses. A client that does not hear back retries, and it retries with the same key. The first call under a key runs the send, every later call returns what the first one produced, and the rail is touched once:

python
from withdrawals import Submissions

submissions = Submissions()

def send(pending):
    return pending.approve().start_sending().mark_sent("0xdeadbeef")

first = submissions.submit("client-req-9f21", request, send)
again = submissions.submit("client-req-9f21", request, send)

assert again is first

The same key carrying a different withdrawal raises IdempotencyConflict rather than paying twice. And the case I care about most: when the send itself raises, the key stays claimed and a retry raises SubmissionInFlight, because nobody yet knows whether the rail saw it. An RPC timeout is not a failure and it is not a success. It is a third outcome, and I think the only honest response is to refuse to guess. You close it deliberately once it has been reconciled, with resolve(key, outcome) if the send did land, or release(key) if it did not.

The state machine holds the same line on the callback side. Replaying a transition that already happened returns the same value, so a confirmation delivered twice costs nothing, while a replay carrying different data (the same withdrawal marked sent under a different reference) raises InvalidTransition instead of silently overwriting what you already told your accounting system.

What I would do differently#

In earlier systems I stored approvals in a generic audit table and a boolean on the withdrawal row. It reads as separation of concerns and it is not: the audit table is written for humans, so nothing in the code path ever reads it back, and the boolean is the only thing the sender consults. Once the threshold changes, the boolean is a claim with no supporting evidence attached, and reconstructing which rule applied means joining against a table nobody maintained as a source of truth.

I would also not key retries on the withdrawal id again. It feels natural (one withdrawal, one send) but it quietly assumes the client cannot generate two withdrawals for the same intent. Clients under a timeout do exactly that. The key has to come from the caller, because the caller is the only party that knows whether this is a new intent or the same one again.

Write the three identities down explicitly when you design this: the approver decides whether the request may leave requested, the request decides which transitions are legal, and the client's request key decides how many times the rail is touched. None of them is the click.

What is left is a pipeline in which the interesting decisions are values you can log, replay and test without a chain, a bank, or a person in the room. The senders are the easy part.

react

$ new-project --brief

or email hey@polycratia.com