Measured, not asserted
Published
Number portability means a telephone number no longer says who serves it. A number issued inside one carrier's allocation may today be served by an entirely different one, and the only thing that knows is a portability record. That gives every routing decision two layers, and the second layer is not an error path: if the portability record cannot be found, the switch routes on the number block, which is what it did before portability existed and what it must still do for the overwhelming majority of numbers that have never moved. This test renamed the record layer against a real obfuscation engine. Nothing failed. Nothing was refused. Three subscribers' calls were delivered, promptly and in full, to a network that no longer had them.
A fallback that is correct for most numbers is catastrophic for the rest
The fixture is a small routing table and a port-out desk. Seven calls: three to numbers that have been ported away from their original allocation, four to numbers still with their block owner. Four port-out requests at the desk, two of which must be refused -- one account is in arrears at 214.50, and one request quotes a PIN that does not match the account. The correct run routes three calls on a port record and four on the block owner, misroutes none, approves two requests and refuses two.
The thing worth noticing before any renaming happens is that the fallback branch is not defensive coding. It is the answer. Routing on the number block is exactly right for the four unported calls, and the engine has no way to distinguish "this number was never ported" from "this number was ported and I cannot see the record". Both look identical from inside: a lookup that returned nothing, followed by a perfectly good allocation prefix.
So the failure this area produces is not a missing value, not a NaN, not a throw and not a refusal. It is a confident, well-formed, promptly delivered route to the wrong carrier.
Renaming the ported-number table misroutes three subscribers and reports nothing
Rename the option carrying the portability records and the library falls back to its own default, an empty table. The run reports on-port-record=0 on-block-owner=7 unroutable=0. Every call is routed. No call is unroutable. Nothing throws, and the routing function returns success.
The measured damage is MISROUTED=3, and that counter exists only because this fixture was built to carry the ground truth alongside the decision. A real switch does not have it. What a real switch has is exactly the three figures above, all of which look healthy.
The same answer arrives by a second, entirely independent route. The ported-number table is keyed by quoted MSISDN strings, and renaming those quoted keys -- ^(447700900101|447700900412|442071838750)$ -- produced output identical to renaming the option that holds the table. A lookup table keyed by quoted identifiers is reachable by a member pattern exactly like a dotted name; a quoted key is not out of scope.
Renaming the record field the lookup is performed on, msisdn, is worse and no louder: MISROUTED=7. Every call in the run goes to the wrong place, because the block-owner fallback is keyed on the same field, and the run still reports success.
The record can be FOUND and still route wrongly
There is a third route to the same damage that is subtler than losing the table. Rename recipient -- the field inside each port record that names the carrier now serving the number -- and the lookup still succeeds. The run reports on-port-record=3: three calls were matched against a portability record, exactly as they should have been. The record was found. The record was used. The carrier it named came back undefined, and MISROUTED=3.
This matters because the obvious health metric for a portability system is how many calls resolved against a port record rather than falling back. That metric is correct in this arm. It is the arm where the system looks most normal.
For contrast, renaming lrn -- the location routing number carried alongside the recipient -- moved the displayed record and changed nothing about where calls went. It is an honest inert arm, and it is worth reporting: not every field on a record is load-bearing, and guessing which ones are is what the measurement replaces.
Renaming the block table instead, or its quoted allocation prefixes, produced the loud failure: the four unported calls became UNROUTABLE. That is the direction anybody notices within minutes. The quiet direction is the one that leaves every call connected.
The detector and the thing it detects are lost by one pattern
This fixture carries the answer each routing decision is trying to reproduce, so the run can count misroutes. Rename that ground-truth field on its own and the count reads MISROUTED=0, which is true: nothing was misrouted in that arm.
Now rename the ported-number table and the ground-truth field together -- one pattern, both names. The result is on-port-record=0 on-block-owner=7 MISROUTED=0. Three subscribers are receiving no calls, and the counter built to notice says zero.
This is a shape this series has measured before in other domains and it generalises well past obfuscation: any monitoring value that lives in the same namespace as the thing it monitors can be lost in the same edit. A dropped column over an integration hop, a serialisation that omits undefined keys and a hand-written mock all produce it. The mitigation is placement, not cleverness -- reconcile against something written on the far side of a boundary, such as a sample of calls confirmed delivered by the terminating carrier, rather than against a field your own bundle wrote.
The port-out PIN: two halves that each fail closed, and together fail open
The second half of the fixture is the port-out desk, where the anti-fraud control lives. A port-out PIN exists because a telephone number is now a second factor for everything else; a number taken over is an account-recovery channel taken over. If you are assessing that risk on a mobile client, our OWASP MASVS resilience notes cover where the client-side half of it sits.
Renaming the enabling flag alone approved a request that quoted the wrong PIN: APPROVED went from 2 to 3. That is the expected result and it is bad enough. The interesting cell is elsewhere.
Rename the PIN presented on the request, on its own, and every request is refused -- APPROVED=0, including the two legitimate ones. Rename the PIN held on the account, on its own, and the same thing: APPROVED=0. Both halves fail closed, loudly, in a way the desk notices within an hour.
Rename both together and APPROVED=3. The comparison is now undefined against undefined, which is true, so the check passes and the fraudulent request goes through. Two names, each individually producing a loud refusal, producing a silent approval when lost together. A reviewer who tests each half and finds each one screaming has learned nothing about the pair.
Two inert halves, one dangerous union
The arrears control repeats the pattern in a different shape. The desk enforces a balance limit two ways on purpose: a published limit on the policy object, and the carrier's own eligibility rule, which carries its own threshold in code.
Rename the published limit alone: APPROVED=2, byte-identical to the correct run, because the eligibility rule still catches the account in arrears. Rename the flag that enables the eligibility rule alone: APPROVED=2 again, because the published limit still catches it. Both halves read completely clean -- the exact finding a reviewer closes as cosmetic.
Rename both and APPROVED=3: an account 214.50 in arrears ports out. Neither the narrow pattern nor the wide one is where the danger lives; it is at one specific combination, and a clean reading on each half individually is not evidence about the pair.
Renaming the caller-supplied eligibility rule itself was also inert here, for the honest reason that the published limit and the PIN check between them still refuse both bad requests. Defence in depth worked in that cell. It stopped working the moment one pattern reached two layers, which is the case above.
What to change, whether or not you obfuscate
Make an unresolvable portability lookup an error rather than a fallback for numbers you know have been ported. The fallback is only correct for numbers that were never ported, so hold that fact explicitly: if the record set is smaller than the last known good count, refuse to route on the block owner rather than silently pretending nothing has moved.
Reconcile against the far side. Count calls confirmed delivered by the terminating carrier against calls you believe you routed there, per carrier, per day. That number is written by somebody else and does not move when your names move.
Write a comparison whose two sides cannot both go missing. The PIN result above is undefined-equals-undefined; comparing a presented secret against a stored one should refuse when either side is absent, not when they differ. A single explicit "both values must be present strings" guard turns the pass-open cell into a pass-closed one.
And treat quoted keys as in scope. If you use member renaming with a pattern, remember that a table keyed by quoted numbers, prefixes or identifiers is renamed exactly like a dotted property. The safest posture is to keep renaming patterns narrow and explicit, and to keep any table whose keys are data out of the pattern entirely.
How this was measured
Five telecommunications areas were driven through the real obfuscation engine on five presets -- the ES5 default, the modern target, the emit gate's option set on both targets, and the string transforms. All five areas produced output byte-identical to the unprotected run on all five presets, twenty-five for twenty-five. Protection on its own changed nothing, which is the finding that lets the rest of the article be about renaming specifically.
The member-renaming column is ninety-six patterns run on two presets, one hundred and ninety-two verdicts. One hundred and seventy-eight moved the output; fourteen did not, and all fourteen are the seven options deliberately pinned equal to the library's own default, measured on both presets. Nothing threw, nothing was vacuous, and the ES5 and modern targets agreed on every arm.
Before any of that, every option key and every record field in the fixture was deleted in the unprotected world to confirm it changes the answer at all. Ninety-two arms; two were structurally blind on the first run -- both of them the ground-truth fields, invisible because the correct answer already reads zero misroutes -- and both were fixed by printing the basis of the comparison rather than only its verdict. An arm that cannot move is not evidence of safety.
Every figure quoted above came from executing the protected artifact and reading its whole output line, not from a diff summary.
Frequently asked questions
Did obfuscation on its own break number portability routing?
No. Five presets -- the ES5 default, the modern target, the emit gate's option set on both targets, and the string transforms -- all produced output byte-identical to the unprotected run across all five areas measured this pass.
What actually broke it, then?
Member renaming with a pattern that reached the names the routing decision is made of: the ported-number table, the MSISDN the lookup is keyed on, the recipient carrier inside each record, and the port-out PIN on both sides of its comparison.
Why did no call fail?
Because routing on the number block is the correct answer for any number that was never ported, and the engine cannot tell that case apart from a portability record it failed to read. Both look like a lookup that returned nothing followed by a valid allocation prefix.
Are quoted keys in a lookup table renamed?
Yes. Renaming the quoted MSISDN keys of the portability table produced output identical to renaming the option that holds the table. A table keyed by quoted numbers or identifiers is reachable by a member pattern exactly like a dotted property name.
Why did renaming the PIN on one side fail safely but on both sides fail open?
One side missing makes the comparison undefined against a real value, which is false, so every request is refused. Both sides missing makes it undefined against undefined, which is true, so the check passes and a wrong PIN is accepted.
Does a misroute counter protect me?
Only if it reads a value written outside your bundle. Renaming the portability table and the ground-truth field in one pattern left three subscribers misrouted and the counter reading zero.
What is the safest way to use member renaming here?
Keep the pattern narrow and explicit, exclude any table whose keys are data, and never let one pattern reach both a control and the check that watches it. Renaming identifiers and control flow is unaffected by all of this.
Related reading