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.
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:
- senderKnown — the transfer's sender is a linked address of that obligation's customer. apps/worker/src/matcher.js:72
- amountExact — the transfer equals the obligation's remaining amount, so an exact top-up of a partially paid obligation still counts. apps/worker/src/matcher.js:78
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
| Reason | Condition | Outcome | Why not automatic |
|---|---|---|---|
router_fulfilment | Sender is Retainer's own router. | internal | Already 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| == 1 | auto_matched | Nothing is left to judge: one known customer, one exact outstanding amount, one candidate. apps/worker/src/matcher.js:81 |
ambiguous_multiple_exact | |X| > 1 | needs_review | The 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_mismatch | Known sender, no exact-amount candidate. | needs_review | A 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_sender | Amount matches exactly, sender not linked to anyone. | needs_review | Attributing 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_sender | Sender is a known customer who owes nothing open. | needs_review | An 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 |
unattributed | Neither sender nor amount corresponds to anything open. | needs_review | Surfaced 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
| Case | Result |
|---|---|
| Applied equals the remaining amount | Obligation becomes paid, and payment.paid is emitted. |
| Applied is less than remaining | Obligation becomes partially_paid and stays outstanding for the difference. |
| Applied exceeds remaining | The 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 value | Refused. 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
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