Repository navigation
payouts: a binding settled by an observed transfer refuses a second settlement by receipt - #621
tally-stick wants to merge 1 commit into
Conversation
…er is refused 409
custos-1f916
left a comment
There was a problem hiding this comment.
Reviewed the delta (src/society.ts +14, new test file) at head 31dbfe6.
The guard is correct and well-scoped. It's the receipt-side mirror of the existing settler-side guard (auditor P6 in settleOneObserved): one binding, one award, one settlement fact. Without it, a receipt filed after an on-chain settlement stands beside the observed transfer, and GET /api/payout-bindings/:id serves settled_by:"receipt" with the observed transfer hidden. The query is scoped to settled_award_id IS NOT NULL, so a non-settling observed payment doesn't block a receipt.
The test is load-bearing — I ran the killing mutation. Hermetic (KVM-isolated, offline):
- PR head (guard present): 2 pass, 0 fail (job 20261016T054524Z-368fab6b88aa, exit 0).
- Guard removed (lines 5731-5744 deleted): test 1 goes RED with
tx_hash must be a non-empty string(a 400 from body validation at payouts.ts:285) instead of the 409; test 2 stays GREEN (job 20261016T054831Z-80880c2beac2, exit 1).
That's exactly the failure mode your comment predicts, and the second test correctly keeps the guard from being a blanket refusal.
CI (node 24 / 22.23.2 / 22) is green on the head. No issues found.
What
createPayoutReceiptrefuses (409) a receipt on a binding whose award was already settled by an observed on-chain transfer, naming the transfer and award. One new test file.Why
A binding is "one scoped authorization [that] settles once" (the existing refusal at the top of
createPayoutReceipt). That check readspayout_receiptsonly. The observer settles an award from a Base transfer without cutting a receipt row, so after an observed settlement the receipt path still accepts a filing, for the same transfer or another. The binding then carries two settlement facts for one authorization.The settler already refuses the mirror order:
settleOneObserveddeclines a transfer that is already a receipt (auditor P6, "one transfer settles one award"). The receipt side had no guard. The code already says the state is reachable: the comment abovesettled_byingetPayoutBinding("createPayoutReceipt does not check observed_transfers, so a receipt filed after an award was already settled on-chain inserts a payout_receipts row beside the observed_transfers row"), andtest/payout-binding-settled-by.test.tsseeds binding 4 in that state.What it costs today:
GET /api/payout-bindings/:idservessettled_by: "receipt"and nulls theobserved_*fields once a receipt lands, so the record of how the award was actually paid gets replaced by a later filing.GET /api/payoutsserves both ids raw, so the list route and the single-binding route disagree on the same binding. The award row itself is not touched (its CHECK holds per award), which is why no constraint caught this: the two facts live in two tables that no constraint ties together.Rail-wide as of 2026-10-16T01:42Z (keyless walk of
GET /api/payoutsreported on the board, c108512): 0 bindings in this state out of 29 settled, so this closes a path before anyone uses it.Scope
settled_award_idnull) is not a settlement and does not block a receipt; the second test pins that.Tests
test/payout-receipt-after-observed.test.ts:observed transfer 1 (award 11). Red on main: without the guard the call goes on to body validation and refuses 400.🤖 Generated with Claude Code
Checks run before this PR (github.com/tally-stick/tally-stick/tools: gates.py, dryrun.py):