Measured Behaviour
Published
A deposit limit system has obvious failures everybody tests for: a limit that will not save, an amount that will not parse, a change that never applies. It has one almost nobody tests for, because it is not a property of any request - the DIRECTION the change moves in. We measured what a rename does to it.
A control that is made entirely of an asymmetry
Every licensed market requires the same shape. A player may tighten a deposit limit whenever they like and it binds at once. A player who wants to loosen one waits out a cooling-off period and then says so a second time. The numbers are not the control. The asymmetry is the control, because a limit that can be raised the moment it starts to bind is not a limit.
We built that as it actually looks: three players over a four-day window, a standing weekly limit of GBP600, a 24-hour cooling-off period, a required second confirmation, one limit across the whole account rather than one per product, and a cap saying no single step may raise a limit by more than 50%.
Five change requests, all genuine. One player setting a first limit of GBP300, with nothing on record before it. One asking to go from GBP600 to GBP5,000. One asking to go from GBP600 to GBP2,000 and confirming two hours later, well inside the wait. One cutting GBP900 back to GBP100. One re-affirming GBP600.
The correct run applies four of the five. The GBP5,000 request is read as an increase, so it waits its 24 hours, needs the confirmation that arrives at hour 34, and is trimmed by the step cap to GBP900. The GBP2,000 request is confirmed too early and never binds at all. Ten deposits are attempted, four are accepted, six are refused, and GBP1,700 is taken.
A direction is not a value
Here is the part that decides everything else. A direction is not written on any record. Every request carries the limit in force and the limit asked for, and both of those are true. Whether the request is a tightening or a loosening is a RELATION between them, and it exists only while both can be read.
So what happens when one of them cannot be? The library we built does what a library in this space has to do: it treats an unreadable current limit as a first-time limit, and a first limit is a tightening - it moves the player from unlimited to limited, and making them wait a day for that would be indefensible. That reading is CORRECT for the player setting their first limit. It is the same reading for everybody else.
We then protected the file with default settings on five profiles: the ES5 target, the modern target, both emit-gate configurations and the string-encoding profile. All five produced identical behaviour, and we re-ran the whole base column a second time and diffed it identical. That is the first result and it is the one most readers want: protection alone did not change what this system does.
What renaming the current limit actually did
The second column is member renaming, and the arm that matters is the one that moves the field naming the limit already in force. It is a field the bundle writes and the installed library reads, so renaming moves the write and leaves the read where it was.
Nothing broke. Requests stayed at five, deposits at ten, and the count of unreadable requests stayed at zero. What moved is that all five changes were classified as decrease - including the request to go from GBP600 to GBP5,000, which was granted in full and bound at hour 6 instead of being trimmed to GBP900 and bound at hour 34.
Applied went from four to five. Deferred went from one to zero. Capped went from two to zero, because a step cap only ever applies to an increase and there were no longer any increases to apply it to. Accepted deposits went from four to seven, refused from six to three, and the money taken went from GBP1,700.00 to GBP3,500.00. Annualised at the same rate that is GBP155,125 against GBP319,375.
The operator's own limit check passed in both runs. It asserts that no deposit was taken above the limit in force, and that is a true statement about the limit THIS RUN computed. Both renaming presets produced byte-identical output.
The control everybody photographs did nothing
The cooling-off period is the artefact that goes in the compliance file. We measured losing it on its own, and the result is worth the whole article: money taken stayed at GBP1,700.00, deposits accepted stayed at four, refused stayed at six, and the pounds taken above the standing limit stayed at GBP300.00. The only thing that moved was a cap figure printed beside two deposits.
Losing the cooling-off period AND the second confirmation together is also not the bad case. The step cap survives both, so the GBP5,000 request is still trimmed to GBP900 - it simply binds at hour 6 rather than hour 34 - and the money taken comes out at GBP1,500.00, which is LOWER than the correct run.
So the ranking is the opposite of the intuitive one. The delay everybody names, lost by itself, moved no money at all in this fixture. The pair of named controls, lost together, moved the money down. The unnamed record field, lost by itself, doubled it, because it is the one thing that decides whether the other three are consulted.
Why a test suite would not have caught it
We ran the same five requests again with every one of them turned into a tightening, which is the fixture most people would build. A test suite for a responsible-gambling control is written by somebody thinking about players protecting themselves, so every request in it goes the protective way.
In that fixture the arm is completely inert. A decrease read as a decrease is right, and an unreadable direction is read as a decrease, so the defective run and the correct run agree on every counter. The defect is invisible to test data built out of good intentions.
That is the general shape and it is worth stating plainly. Thirty-one measured failure shapes into this series, every one has concerned a VALUE - missing, unreadable, in the wrong unit, over the wrong set, off the wrong row. A direction is not a value. It does not go missing; it collapses to whichever reading is defensible when only one operand survives, and here that reading is the permissive one for everybody except the player it was designed for.
What to do about it
The mitigation is not a wider test suite over the same output, because the output is well-formed and mostly correct. It is two things, both cheap.
First, make the direction EXPLICIT on the request rather than derived from two fields. A request that carries its own intent - tighten or loosen - and is refused when that field is missing cannot silently become the other one. This is worth doing regardless of obfuscation, because a dropped key over a serialisation hop, a partial API response and a hand-written mock all produce the same undefined.
Second, refuse to classify rather than defaulting. A library that cannot read the current limit should raise, not assume. The first-time-setter case is real, and it is better served by an explicit null-means-first-limit contract than by a fallback that also swallows every genuine loosening.
On the protection side the fix is scoping, and it is one line. Member renaming is opt-in and takes a regular expression, so a pattern that excludes the names crossing the boundary to an installed library keeps everything else renamed. The mechanics are on the RenameMembers documentation, and the general point is that names your own code owns on both sides are safe while names a third party reads are not. If you are shipping a client-side control into a payments-regulated product, the same discipline is what our PCI DSS guidance is built around.
Frequently asked questions
Does JavaScript obfuscation break deposit limits by default?
No. We protected the same deposit-limit bundle on five profiles - the ES5 target, the modern target, both emit-gate configurations and the string-encoding profile - and all five produced behaviour identical to the unprotected run. The base column was measured twice and diffed identical. The failures in this article come from member renaming, which is opt-in and takes a pattern you choose.
What exactly went wrong when the current limit was renamed?
The library could no longer compare the limit asked for against the limit in force, so it could not tell an increase from a decrease. It fell back to reading an unreadable current limit as a first-time limit, which really is a tightening, and applied every change immediately. All five requests were logged as decreases, including one that raised a limit from GBP600 to GBP5,000.
How much money did that move?
Deposits accepted went from four to seven and money taken went from GBP1,700.00 to GBP3,500.00 across a four-day window, with GBP2,050 of it above the standing limit against GBP300 in the correct run. Annualised at the same rate that is GBP319,375 against GBP155,125. Both renaming presets produced byte-identical output.
Did anything report an error?
No. The operator's own limit check passed in both runs, because it asserts that no deposit was taken above the limit in force and that is a true statement about the limit the defective run computed. The cooling-off period still printed as 24 hours and the account scope still printed correctly. Nothing in a configuration dump moved.
Would losing the cooling-off period be worse?
Measured, it was not. Losing the cooling-off period on its own left money taken at GBP1,700.00, deposits accepted at four and refused at six - identical to the correct run, with only a printed cap figure moving. Losing it together with the required second confirmation brought the money DOWN to GBP1,500.00, because the 50% step cap survives both and still trims the large request.
Why did our test fixture not catch this?
We re-ran the whole set with every change request turned into a tightening, which is how test data for a responsible-gambling control usually gets written. In that fixture the arm is completely inert: a decrease read as a decrease is correct, and an unreadable direction is read as a decrease. The defect cannot appear in test data where nobody ever raises a limit.
How do we keep the protection and avoid the defect?
Carry the intent on the request rather than deriving it from two fields, and refuse to classify a change whose current limit cannot be read instead of assuming one. On the build side, scope RenameMembers with a pattern that excludes the names crossing into an installed library; the details are on the RenameMembers documentation. Names your own code owns on both sides stay safe.
Related reading