Measured Behaviour

Does obfuscation break fare capping?

A ticketing system has obvious failures everybody tests for: a fare that will not price, a tap that will not parse, a balance that goes missing. It has one almost nobody tests for, because it is not a property of any fare - the ceiling that a whole sequence of individually correct fares is not allowed to exceed. We measured what a rename does to it.

A discount that no transaction carries

We built one account the way one actually looks. A rider with a plastic card and a phone, both on the same account, tapping whichever is in their hand. Twenty-one taps over eight days. A published fare schedule: bus $2.90, rail $4.25, and a premium airport service at $12.50 that the tariff places outside the ceiling. A daily cap of $8.50 and a weekly cap of $34.00, both advertised to riders as a promise - pay as you go and you will never be charged more than the pass would have cost.

At published rates those twenty-one taps come to $85.35. The correct run collects $63.50, because the daily ceiling binds on six of the eight days and the weekly ceiling takes over on the last one. Eight fares are capped, five days end with travel that costs nothing, and $21.85 is never collected.

Here is the part that decides everything else. There is no record anywhere in that system that says a fare should have been free. Every one of the twenty-one taps is charged at the rate its own mode's published schedule states, and that schedule is identical in every arm we measured. The discount is not written on the tap that receives it; it is created by the taps that came before it. A per-transaction audit that asks was this fare priced correctly? answers yes, twenty-one times out of twenty-one, in every arm below.

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. That is the first result and it deserves stating plainly - protection on its own did not change a single fare.

The failures start when member renaming reaches the names on the capping configuration object, which is what a broad RenameMembers pattern over a configuration literal actually does. If you have not yet decided how far your member pattern reaches, the mechanics are in Protect Members.

What the ceiling loss actually did

We wrote the prediction down before measuring. The prediction was that the number of fares would not move, that every fare would still be charged at a published rate, that the capped-fares counter would read zero, that the savings figure would read zero, and that the authority's own check would pass.

Every part of it landed. Fares stayed at twenty-one. The at-published-rate total stayed at $85.35 - not close, identical. Collected went from $63.50 to $85.35. Capped fares went from eight to zero. The free-after-ceiling list, which named five days in the correct run, came back empty.

The savings counter is the one worth dwelling on. In the correct run it reads $21.85. In the renamed run it reads $0.00 - and it is not lying. That figure is computed from the cap table, and the cap table is the thing that went missing. A run in which no ceiling exists has saved nobody anything, so there is nothing to report. The number a revenue analyst would check to notice this is priced against the very thing that broke.

And the authority's own rule passed. It asserts that nothing was collected above the published rate for the fares presented, and that is a statement about the fare schedule. A run whose cap table is unreadable collects every fare at exactly its published rate. The rule printed 21 fare(s), $85.35 collected, 0 capped, 1 excluded, nothing above a published rate, and every word of that is true.

The only line that visibly broke is the one the rider is owed: the account was promised at most $68.00 across the two capping weeks in the extract, and it was charged $85.35. That is a $17.35 breach of a published consumer commitment, on an account where every individual fare would survive a dispute.

Three keys, one outcome, and the configuration screen still right

The capping object carries the ceilings themselves (capTable) and a separate flag saying whether to apply them (honourCaps). Losing either produced the same $85.35, on the same counters, to the cent. They are two sides of one decision and one rename pattern reaches both.

What differs is only the configuration echo. In the honourCaps arm the screen still reads DAILY:$8.50 WEEKLY:$34.00 - the ceilings present, correct and exactly as published - beside a policy line that says they are recorded but not applied. A populated configuration screen is evidence that the ceilings EXIST. It has never been evidence that anything consults them.

A third key gets almost the whole way there by a different route. capScope says the card and the phone share one ceiling, because they belong to one traveller who bought one ceiling. Lose it and the vendor's per-medium default splits the account into two riders: $78.20 collected, still $10.20 over the published promise, with the ceilings themselves untouched and the capping engine working perfectly - on the wrong population.

The key on the same object that costs the operator instead

Everything above costs the rider money. One key on the same literal, three lines away, costs the authority money, and it is worth understanding why, because it decides how you have to test this.

capExcludedModes names the premium airport service as sitting outside the ceiling - riders are told so when they buy it. The vendor's default for that key is an EMPTY list, meaning nothing is excluded. Lose the key and the $12.50 airport fare starts counting toward the daily and weekly ceilings it was never meant to touch. Collected falls from $63.50 to $51.00. Ten fares are capped instead of eight, six days go free instead of five, and the rider is now comfortably inside a promise the authority is funding out of its own revenue.

So one object, one rename, opposite directions depending on which key you lose: up to $21.85 out of the rider on three keys, $12.50 out of the operator on a fourth. The severity of losing a name is not a property of the name and not a property of how important the option sounds. It is whichever direction that library's default happens to point, and two options on one object pointed opposite ways.

The measurement consequence is the load-bearing part. We ran the union - both keys lost together, which is what a broad, realistic "rename the whole options object" pattern does - and it came back at $85.35, identical to losing the cap table alone. With no ceiling at all, an exclusion from the ceiling has nothing to exclude from. The arm that costs the operator $12.50 is invisible under the broad pattern and only appears under a narrow, careful, well-intentioned one that spares one key and takes its neighbour. Test the halves, not just the union; the dangerous cells are not at the wide end.

The two date keys, and one arm that moved almost nothing

Two more keys on the object decide which period a fare falls into rather than what it costs. serviceDayStartHour says the fare day begins at 03:00, because a network whose last trains run past midnight does not start a new day at 00:00. Lose it and a 01:00 tap moves onto the following day: collected goes from $63.50 to $63.70. Twenty cents.

That twenty cents is not evidence that the key is safe. It is evidence that in this particular extract the weekly ceiling re-absorbed almost all of the movement. On an account whose weekly ceiling does not bind, the same loss moves a full fare onto the wrong side of a daily ceiling every time the rider travels late. An arm that measures nearly clean against one account is not an arm that is clean.

weekStartDay is louder in the other direction: lose the week boundary and the eight days collapse into one counted period, the $34.00 weekly ceiling binds four days earlier than it should, and collection falls to $53.65. Nothing is unreadable, nothing throws, and every fare in the receipt is still a published rate.

The negative control, and why constructed test data cannot see this

We ran the same twenty-one fares against an account that never reaches a ceiling - the same modes, the same rates, the same order, spread thinly enough that each tap sits on its own day. Its output is identical to the broken arm on every counter: twenty-one fares, $85.35 collected, zero capped, $0.00 saved, no free days.

That is the uncomfortable part of this whole area. Constructed test data is a handful of trips spread thinly. A fixture built to check that fares price correctly will price them correctly, in both arms, forever. If your capping test never reaches a ceiling, it is not a weak test of capping - it is byte-identical to the defect's own output, and it will pass on a build that charges every rider full price all week.

The mirror confirms it. One field away on the same records, renaming fareUsd or calendarDay produced no readable fare(21 of 21) -- refused before collecting: a loud, immediate, unmissable refusal. Losing the VALUE fails closed. Losing what the values MEAN fails open, silently, and reads better than the correct run.

What we would actually do about it

The mitigations here are worth having regardless of obfuscation, because a hand-edited config file and a dropped key over a serialisation hop produce exactly the same output.

Assert the ceiling against a source outside the capping code. The published cap is a consumer commitment; compare the period total to it directly rather than trusting a savings figure that is computed from the table you are trying to verify. A rule of the form no account was charged more than the advertised weekly cap catches every arm in this article that costs a rider money, and it is one line.

Make an unreadable cap table an error rather than an absent ceiling. An empty cap table and a genuinely uncapped product are indistinguishable to every check defined over the table itself, which is why the authority's own rule passed.

And build one fixture that actually reaches a ceiling, with more than one medium on the account and at least one excluded product. Every finding above is invisible to a fixture without those three properties, and most fixtures have none of them.

If you are shipping a ticketing or metering front end and want the same protection posture without this class of surprise, Protect Paywalled and Subscription Content JavaScript covers the same ceiling-over-a-sequence problem on the entitlement side.

Frequently asked questions

Does obfuscating my JavaScript change how fares are priced?

Not on its own. We measured 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 same twenty-one fares, the same $63.50 collected, the same eight capped fares. Protection by itself did not move a cent.

Then what actually broke fare capping?

Member renaming reaching the names on the capping configuration object. The library reads its options by name; a name it cannot find is not an error, it is a key you never supplied, so the library substitutes its own default. The vendor default for a cap table is no cap table, and no cap table means every fare is collected in full.

Would our tests have caught it?

Only if your fixture actually reaches a ceiling. We ran the same twenty-one fares spread thinly enough that no cap ever binds, and that control produced output identical to the broken run on every counter. A capping test whose rider never hits the cap is byte-identical to the defect's own output.

The savings counter read zero. Does that not prove nothing was lost?

It proves the opposite, and this is the trap. The savings figure is computed from the cap table. In a run where the table cannot be read, no ceiling ever binds, so nothing was saved and the counter is telling the truth. Any figure priced against the thing that went missing will read clean in exactly the arm you need it to read dirty.

Our configuration screen still shows both ceilings. Is that not evidence?

No. In the arm that loses the honour flag rather than the table, the screen reads DAILY:$8.50 WEEKLY:$34.00 exactly as published, beside a policy line saying the ceilings are recorded but not applied. A populated configuration screen is evidence that the values exist. It is never evidence that anything consults them.

Does every lost key cost the rider money?

No, and that is why you cannot test only the broad pattern. On the same object, losing the excluded-modes list costs the AUTHORITY $12.50, because a premium fare starts counting toward a ceiling it was never meant to touch. Losing both that key and the cap table together came back identical to losing the cap table alone, so the operator-side loss only appears under a narrow pattern.

How do we protect a ticketing front end without this risk?

Scope the member pattern so it cannot reach configuration keys or record field names, and verify against a fixture that genuinely reaches a ceiling. Protect Members documents how the regexp is applied and what a safe scope looks like.

Related reading