Measured Behaviour
Published
Every failure this series has measured ends in an answer whose contents are wrong: a value missing, in the wrong unit, over the wrong set, split the wrong way, off the wrong row. A transfer offers none of those. Every boarding is genuine and every leg is charged a published fare. The only thing that moves is how many journeys the boardings formed - and that number is written on no record at all.
A number that no record carries
We built one service day the way one actually looks. Fourteen taps: a commuter with a morning commute, a midday errand, an evening commute and a late trip out and back, plus a second rider who boards rail and connects to a bus fifteen minutes later. A published fare schedule - bus $2.40, rail $3.10, tram $2.40. A transfer table with six mode pairs, each carrying the number of minutes inside which the second boarding continues the same journey, and a $0.75 upgrade when a bus journey continues onto rail.
The correct run assembles those fourteen taps into seven journeys. Seven of the legs continue a journey the rider had already paid for. The day costs $19.00 against $35.70 at full fare, and over a twenty-one day commuting month, $399.00 against $749.70.
Now look at what a transfer actually is. It does not change a fare. It does not change a rate, a population or a split. Every boarding really happened, at the stop and minute recorded, on the route recorded, and is charged the fare that boarding's own mode's table states. What the transfer table decides is how many BILLABLE UNITS those boardings formed - and the number of units is not a field on any tap. It is created by the sequence.
Protection alone changed nothing. Five profiles - the ES5 target, the modern target, both emit-gate configurations and the string-encoding profile - all produced identical behaviour. The failures start when member renaming reaches the names on the transfer configuration or on the transfer table's own rows, which is what a broad RenameMembers pattern over a configuration literal does. The mechanics are in Protect Members.
Seven journeys become fourteen, and every fare is correct
The prediction was written down before measuring: taps would not move, every leg would still be charged a published fare, journeys would rise from seven to fourteen, the continued-leg counter would read zero, the savings figure would read zero, and the operator's own rule would pass.
It landed in every part. Taps stayed at fourteen. Journeys went to fourteen - one per tap. Continued legs went from seven to zero. Upgrades went from two to zero. Charged went from $19.00 to $35.70 on one day, and from $399.00 to $749.70 over the commuting month. The at-full-fare figure, $35.70, is identical in both arms, because it was never the thing that moved.
Read the receipt from the broken run and there is nothing to object to. Fourteen boardings, fourteen journeys, each priced at the published rate for its mode: $2.40 for a bus, $3.10 for rail. Every line is defensible on its own. The error does not live in any line; it lives in the fact that there are fourteen of them instead of seven.
The saved-by-transfer counter reads $0.00, and it is not lying. It is computed from the transfer table, and the table is what went missing. A network on which nothing continues anything has given nobody a free connection, so there is nothing to report.
And the operator's own fare rule passed, printing 14 tap(s), 14 journey(s), 14 full fare(s), $35.70 charged, every leg at a published fare. Every figure in that sentence is true. It also reads better than the correct run: more journeys per rider and more revenue per rider is what a healthy network looks like on a dashboard.
Three separate keys, one identical outcome
The transfer configuration carries the table itself, a network-wide window of ninety minutes, and a flag saying whether to honour continuations at all. Those are three genuinely different controls: one is data, one is a bound, one is a switch. We measured all three separately.
All three produced the same fourteen journeys, the same $35.70, the same zeroes on every counter. They differ only in the configuration echo - one arm shows an empty table, one shows the full six-row table beside a window reading no stated window, and one shows both intact beside a policy line saying the table is recorded but not applied.
This is worth stating as a design point rather than an obfuscation one. Two separate controls that both have to hold are not two chances to be right. Defence in depth only survives if the second layer lives somewhere the transformation cannot reach - and a per-pair window and a network-wide window sitting on the same object in the same file are one layer wearing two hats.
A record field reproduced the same headline by a completely different mechanism. Renaming routeId on the taps collapses every route to the same unreadable value, so the network's own rule that riding a route out and back is two journeys fires on every consecutive pair - fourteen journeys again, $35.70 again, from a field nobody would classify as a fare control.
The direction reverses one key away
Two keys on the same object move the number of journeys DOWN rather than up, and both cost the operator money.
maxLegsPerJourney says a journey is assembled from at most four boardings. The vendor default is NO STATED LIMIT. Lose the key and the commuter's five-boarding morning becomes one journey instead of two: six journeys, $16.60 collected instead of $19.00. reboundIsNewJourney says riding a route out and back is two trips. The vendor default says it is one. Lose that key and the late errand and the ride home merge: six journeys, $16.60.
Those two arms produce the SAME TOTAL TO THE CENT from two unrelated options, by two different wrong assemblies - one merges the morning, the other merges the evening. If you are reconciling a day's revenue against a control total, the two are indistinguishable, and so is a third possibility nobody considered.
That direction is the one that never generates a complaint. A rider undercharged $2.40 a day does not ring the call centre. The operator loses about eleven per cent of its pay-as-you-go revenue and finds out at the annual reconciliation, if then.
The negative control is the fixture you already have
We ran the same fourteen boardings - same riders, same modes, same routes, same order - moved apart in time so that no boarding falls within any window of the one before it. Every tap genuinely is its own journey.
Its output is identical to the broken arm on every counter: fourteen taps, fourteen journeys, zero continued, $35.70 charged, $0.00 saved. That is the fixture most teams have. Constructed test data is a list of independent trips, because that is the easy thing to write, and a rider who never connects is blind to this entire class by construction.
The mirror is one field away and it is loud. Renaming mode or tapMinute produced no readable tap(14 of 14) -- refused before charging. Losing the value fails closed and immediately. Losing what the values MEAN to each other fails open, silently, and prints a better number.
What we would actually do about it
Compare the journey count against something outside the fare engine. The transfer table's own counters cannot see this, because they are defined over the table. A daily assertion of the form this rider's journeys should number about half their taps is crude and it catches every arm in this article, in both directions.
Make an unreadable transfer table an error rather than an empty one. An empty table and an operator who does not offer transfers are indistinguishable to every check defined over the table, which is exactly why the operator's rule passed on a run that nearly doubled a commuter's bill.
Do not treat a per-pair window and a network-wide window as two controls. They are one control expressed twice, and one rename pattern reaches both.
And build a fixture whose rider actually connects. A rider with connections, a rider who exceeds the leg limit, and a rider who rides a route out and back are three different fixtures, and each of them is invisible to the other two.
If your fare engine ships inside a mobile app, the same configuration object travels to the handset with it - Protect React Native App JavaScript covers what a member pattern reaches once the bundle leaves your build.
Frequently asked questions
Does obfuscation change how journeys are assembled?
Not on its own. Five profiles - the ES5 target, the modern target, both emit-gate configurations and the string-encoding profile - all produced behaviour identical to the unprotected run: fourteen taps, seven journeys, $19.00 charged. Protection by itself did not move a cent.
What did break it?
Member renaming reaching the transfer configuration. The library reads its options by name; a name it cannot find is a key you never supplied, so it substitutes its own default. The vendor default for a transfer table is an empty table, and an empty table correctly answers 'this pair does not continue' for every boarding on the network.
Is any individual fare wrong in the broken run?
No, and that is the whole point. Every one of the fourteen legs is charged the published rate for its own mode. A receipt is defensible line by line. The error is that there are fourteen billable units instead of seven, and the number of units is not written on any record.
Which counter would have caught it?
None of the ones in the fare engine. Continued legs read zero, saved-by-transfer read $0.00, and both are true, because both are computed from the table that went missing. The operator's own fare rule passed on a sentence in which every figure was accurate.
Can the error go the other way?
Yes, and it did. Losing the leg limit or the same-route rebound rule merges journeys instead of splitting them: six journeys and $16.60 instead of seven and $19.00. Both of those arms produced the same total from different wrong assemblies, and neither generates a customer complaint.
Do our two transfer windows protect each other?
No. We measured the per-pair window and the network-wide window separately and together; all three arms produced the same fourteen journeys and the same $35.70. Two controls that both have to hold are not two chances to be right when one rename pattern reaches both.
How do we protect a fare engine safely?
Scope the member pattern so it cannot reach configuration keys or record field names, and test against a rider who genuinely connects. Protect Members documents how the regexp is applied and what a safe scope looks like.
Related reading