roundFee() must return an integer count of minor units. Floats in the fee path were the reason INT-4790 exists, and the stored value must stay an integer.
Fee rounding: integer minor units meet HALF_EVEN
Overlap: fees/rounding.ts roundFee() · detected by property test, not by text diff
git merge INT-4801 into INT-4790
No conflictsThe two edits touch different lines, so git merges them without asking anyone. It reports success.
@@ -12,6 +12,8 @@ fees/rounding.tsimport { Money } from "../currency/Money"; -export function roundFee(amount: number, rate: number): number {- return Math.round(amount * rate * 100) / 100 + 0.30;+export function roundFee(amount: Money, rate: number): MinorUnits {+ const cents = Math.round(amount.value * 100);INT-4790+ return halfEven(cents * rate) + 30;INT-4801 }
CI on the merged tree
1 property test failingFAIL fees/rounding.prop.test.ts x fee never exceeds 2.9% + 30 minor units Property failed after 1,042 runs (shrunk 14 times) Counterexample: amount = KWD 1.235 (exponent 3, 1235 fils) rate = 0.029 Computed fee = KWD 0.340 (34 units, scaled as if exponent were 2) Allowed fee = KWD 0.066 (ceil(0.029 x 1235) + 30 = 66 fils) Fee is 5.2x the allowed maximum. pass 163 other currencies fail 5 currencies with 3 decimals: BHD, JOD, KWD, OMR, TND
Nothing in the diff shows this. The text is fine and the behaviour is wrong. A human reading the diff would need to know that INT-4790 assumed 2-decimal currencies everywhere.
Negotiation
Structured turns. Each agent argues from its own intent's acceptance criteria (AC).Round 1 · positions
09:36:41 to 09:37:20Rounding must use each currency's ISO 4217 exponent. JPY has 0 decimals, KWD has 3. A fixed factor of 100 gives wrong fees for any currency that is not 2-decimal.
Ran the property suite on the clean-merged tree. It fails for KWD: amount 1.235 gives a fee of 0.340, and the allowed maximum is 0.066. The merge keeps INT-4790's Math.round(amount.value * 100) and puts INT-4801's HALF_EVEN on top of it, so the wrong scale is rounded correctly.
Round 2 · concession and proposal
09:37:21 to 09:38:34Confirmed: INT-4790 hard-coded 100 as the scale. The counterexample is valid and AC-2 does not cover it. Still holds: no float arithmetic, and no lookup that slows the hot path. INT-4790 carries a p99 budget of 0.40 ms for roundFee().
Take the scale from currency/Money.ts minorUnits(code), which reads a frozen table, so there is no I/O. Convert to minor units first, then apply the percentage and HALF_EVEN as integer division. The 2.9% becomes 290 basis points, so no float touches the fee.
Checked the proposal against INT-4790's tests. Fee values stay integers and Fee.toJSON() is unchanged. Flagging one open question that neither intent covers: KWD fees stored before INT-4790 were rounded to 2 decimals. This needs a policy decision, not a code change.
Round 3 · agreement
09:38:35 to 09:39:22Accept the proposal. INT-4790 narrows to "fees are integer minor units in the currency's own exponent". Amends AC-1 wording; criteria otherwise unchanged. Will escalate the historical-fee question to a human.
Accept. HALF_EVEN runs in minor units for all 168 currencies, and the KWD counterexample now gives a fee of 0.066. Reconciliation sent to the verifier.
Reconciled diff
fees/rounding.ts · +9 −2@@ -12,8 +12,13 @@ fees/rounding.ts+import { minorUnits } from "../currency/Money";import { Money } from "../currency/Money";import { halfEven, divHalfEven } from "./halfEven"; const RATE_BPS = 290; // 2.9%const FIXED_FEE_MINOR = 30; // 30 minor units export function roundFee(amount: Money, rate: number): MinorUnits {- const cents = Math.round(amount.value * 100);- return halfEven(cents * rate) + 30;+ const exp = minorUnits(amount.currency); // ISO 4217: JPY 0, USD 2, KWD 3+ const minor = toMinor(amount.value, exp); // integer, in the currency's own scale+ const pct = divHalfEven(minor * RATE_BPS, 10_000);+ return pct + FIXED_FEE_MINOR; } +function toMinor(value: string, exp: number): number {+ const [whole, frac = ""] = value.split(".");+ return Number(whole + frac.padEnd(exp, "0").slice(0, exp));+}
Check on the example: KWD 1.235 is 1235 fils. 1235 x 290 / 10,000 = 35.815, HALF_EVEN gives 36, plus 30 is 66 fils, which is exactly the allowed maximum.
What happens to KWD fees stored before INT-4790?
Existing stored KWD fees from before INT-4790 were rounded to 2 decimals. Re-compute 1,204 historical fees, or leave them? The agents agree the code is correct either way. The choice changes the books, so it is not theirs to make.
Option A · Re-compute 1,204 historical fees
- Posts 1,204 adjusting entries to the journal. Originals stay; nothing is edited in place.
- Net change: KWD 18.420 less in fees across 61 merchants.
- 380 of the fees sit in the closed September period, so Jonas Weber must approve.
- Reversible with a second batch of entries.
Option B · Leave them as stored
- Old rows keep a
scale: 2marker; reads convert them on the fly. - No change to closed periods and no merchant notices.
- Statement matching for KWD shows 1,204 known rounding differences until they age out.
- Can switch to Option A later with no extra cost.
Other open rooms
3 of 17 shown| Room | Colliding intents | Where they overlap | Round | Status |
|---|---|---|---|---|
| ROOM-2207 | INT-4835 INT-4840 Card-testing velocity rule vs webhook retries |
risk/velocity.ts · api/webhooks.ts | 2 of 3 | Negotiating |
| ROOM-2209 | INT-4822 INT-4812 Bank-holiday payout dates vs refund timing |
payouts/schedule.ts · refunds/Refund.ts | 3 of 3 | Verifying |
| ROOM-2203 | INT-4844 INT-4801 Reversed entries vs HALF_EVEN rounding |
reconcile/matcher.ts · fees/rounding.ts | 3 of 3 | Deadlocked, needs human |
Why this is different
- Conflict means behaviour, not text. Git calls two edits a conflict when they touch the same lines. Signalbox calls it a conflict when property tests, invariants or call-graph overlap say two intents break each other.
- GitHub says "mergeable" and ships the bug. The merged
roundFee()had no conflict markers and a fee 5x too high for KWD. The dangerous conflicts are the clean ones. - The owners negotiate, not the human. Each agent argues from its own acceptance criteria with linked evidence, then they propose, concede and accept. The human opens the room only if they deadlock or hit a policy question.
- Agreement is checked, not trusted. The reconciliation is re-run against both intents' criteria, a mutation test and the caller graph before anyone is asked to confirm it.
- At 100,000 agents, humans cannot resolve conflicts one by one. The signaller sees only the decisions that agents cannot make: here, one question about 1,204 stored fees.