Retainer
Payments & matching

The matching engine

An incoming transfer either lands on exactly one obligation for reasons that leave no room for judgement, or it goes to a human. There is no middle setting, and that is a design decision rather than an unfinished one.

Watch mode raises a question pull never has to answer: which obligation does this money pay?A bank transfer arrives with a sender and an amount, and nothing else. The engine's whole job is to decide when those two facts are enough.

The governing rule
Never auto-match ambiguously. A wrong automatic match is worse than no match at all, because it silently marks a customer paid who has not paid — and unlike a missing match, nobody goes looking for it.apps/worker/src/matcher.js:8

Why there is no confidence score

The conventional design gives each candidate a score and matches above a threshold. Retainer deliberately does not, and the reasoning is short: a score invites a threshold, and a threshold is precisely how ambiguous auto-matches happen. Whatever number you pick, some pair of obligations eventually sits either side of it for reasons no one can explain to the merchant afterwards. apps/worker/src/matcher.js:11

Instead there are two booleans per candidate, and no arithmetic on them:

apps/worker/src/matcher.js — the whole decision
S = candidates whose customer owns the sending address
A = candidates whose remaining amount equals the transfer exactly
X = S n A

|X| == 1  ->  auto-match. the only path that writes auto_matched.
everything else -> a human looks at it, with the evidence.

|X| == 1 is the sole writer of auto_matched, mirroring the rule elsewhere in the system that the reconciler is the sole writer of confirmed. Both exist so that a state which means “we are sure” has exactly one author. apps/worker/src/matcher.js:19

The seven outcomes

Every indexed transfer ends in exactly one of these, and the reason is stored on the row rather than inferred later.packages/db/migrations/003_expected_payments.sql:120

ReasonConditionOutcomeWhy not automatic
router_fulfilmentSender is Retainer's own router.internalAlready reconciled as a pull. Recorded as a state rather than filtered away, so it stays countable. apps/worker/src/matcher.js:67
exact_known_sender|X| == 1auto_matchedNothing is left to judge: one known customer, one exact outstanding amount, one candidate. apps/worker/src/matcher.js:81
ambiguous_multiple_exact|X| > 1needs_reviewThe sender is known and the amount is exact for more than one obligation. Choosing would be a coin toss with the merchant's ledger. apps/worker/src/matcher.js:85
amount_mismatchKnown sender, no exact-amount candidate.needs_reviewA short payment is either a partial or an agreed discount. Those mean opposite things about whether money is still owed, and nothing in the data distinguishes them. apps/worker/src/matcher.js:89
unknown_senderAmount matches exactly, sender not linked to anyone.needs_reviewAttributing money to a customer on amount alone is how one customer's payment settles another's invoice. apps/worker/src/matcher.js:96
no_open_payment_for_senderSender is a known customer who owes nothing open.needs_reviewAn early payment, a duplicate, or not a payment. Calling it unattributed would be false — we know who sent it. apps/worker/src/matcher.js:100
unattributedNeither sender nor amount corresponds to anything open.needs_reviewSurfaced rather than discarded, so it can be attributed by hand instead of vanishing. apps/worker/src/matcher.js:106

Six of the seven end in needs_review. That ratio is the point, not a failing: the engine is tuned to be certain when it acts, and the cost of that is a queue. The review queue is where that cost is paid down, and linking a sender is what stops it recurring.

What counts as a candidate

Only open obligations on the same chain and token, and never one that already has a charge in_flight orconfirmed against it — two systems settling one obligation is where double-counting comes from.apps/worker/src/matcher.js:38

Note what that exclusion does not do: such an obligation is still reviewable by a human. The engine refuses to pair them automatically; it does not pretend the obligation is invisible.

Applying a match

Application locks the transfer row, so two concurrent applications cannot together exceed its value, and refuses outright to apply more than arrived. apps/worker/src/matcher.js:123

CaseResult
Applied equals the remaining amountObligation becomes paid, and payment.paid is emitted.
Applied is less than remainingObligation becomes partially_paid and stays outstanding for the difference.
Applied exceeds remainingThe excess is recorded as surplus on the match rather than silently inflating the obligation. apps/worker/src/matcher.js:133
Applied exceeds the transfer's own valueRefused. apps/worker/src/matcher.js:124

Every match carries a confidence, and it records who decided rather than how sure anyone was: the matcher writesexact_known_sender, a human writes manual. apps/worker/src/matcher.js:186 The audit trail therefore always distinguishes a machine decision from a human one, which a numeric score would have blurred.

How this is verified

All seven outcomes are driven end to end against real Base Sepolia transfers, and each assertion has a negative control that flips the one precondition that should change the verdict — confirmed to actually fail, because a check that passes because it never ran is worse than no check. scripts/phase2-drills.mjs:42

npm run drill:matching
case 1: exact, known sender      -> auto_matched / exact_known_sender
   control: unlink the sender    -> needs_review / unknown_sender
case 2: two exact candidates     -> needs_review / ambiguous_multiple_exact
   control: void one of the two  -> auto_matched / exact_known_sender
case 3: known sender, short      -> needs_review / amount_mismatch
   control: equalise the amount  -> auto_matched / exact_known_sender