Dangerous Goods
Published
Two rules refusing the same parcel is what a carrier's dangerous-goods screening looks like after a few years of regulation. This pass protected a file holding both, renamed each alone, and then renamed both. Each alone still refused the parcel and changed nothing but the reason string. Both together accepted consumer fireworks onto a road vehicle.
One refusal, written two ways
The sample is a dangerous-goods configuration of the kind a logistics team wires in ahead of a carrier handover. Class 1.4 and class 4.2 are refused outright by a denylist of hazard-class strings. Separately, each transport mode carries an allowlist of the classes it will take: road accepts 2.1, 3, 8 and 9, and air accepts only 3 and 8. A five-kilogram net-quantity ceiling applies to everything, a declaration is required, and the carrier's own check compares that declaration against the shipper record.
The two class rules are not a contrived redundancy. A denylist is what you write when head office bans something; a per-mode allowlist is what you write when the aviation regulator asks a different question. Nobody removes the first when the second arrives, because the first still works.
Unprotected, consumer fireworks presented for road carriage are refused with forbidden-class(1.4). Class 1.4 is absent from the road allowlist too, so either rule alone would refuse them.
Each rule alone holds, and moves only the reason
Renaming forbiddenClasses alone leaves the fireworks refused. The denylist reports (none - no hazard class is refused), so the first rule has no opinion, and the per-mode allowlist catches the parcel instead. The only thing that changed is the reason string, from forbidden-class(1.4) to class-not-permitted-by-mode(1.4 by road). Same outcome, different label.
That is, on its face, precisely the finding a reviewer closes as cosmetic. A log message changed. No prohibited parcel was accepted. The safety property held, which is what the second rule was there for.
Renaming permittedByMode alone is not so quiet, and the difference is instructive. Class 9 lithium batteries are not on the denylist at all -- they are perfectly legal by road and refused only by air. With the per-mode allowlist gone, the lithium parcel is accepted for air carriage: accepted(p-3 class=9 1kg by air). The denylist could not catch it because it was never written to.
Both together, and the fireworks are on the truck
Renaming forbiddenClasses and permittedByMode in one pattern accepted the fireworks: ACCEPTED=true accepted(p-2 class=1.4 2kg by road). The lithium battery went onto the plane in the same run. Nothing threw and nothing warned. Both configuration lines report the permissive default, which is a true statement about a configuration nobody re-reads at load time.
The two rules were written as independent controls, and in one sense they were: each caught a case the other missed. What they were not is independent of the transformation. They are two properties of the same options object in the same file, and one anchored pattern naming both reaches both.
The generalisation this series keeps arriving at is that a second layer only helps if it lives where the transformation cannot follow. Two keys on one literal do not satisfy that. Re-checking the hazard class on the receiving side, in code that ships separately, does.
The same allowlist, failing closed one way and open the other
The per-mode allowlist appears twice in these results, and the two appearances point in opposite directions. Renaming permittedByMode -- the option that carries the list -- accepted a parcel that should have been refused. Renaming hazardClass -- the field on the parcel that the list is matched against -- refused everything, including the four litres of correctly declared paint that must always be accepted: class-not-permitted-by-mode(undefined by road).
Allowlists have a reputation in this series for failing closed, and that reputation is earned against an unknown entry. A hazard class the list has never heard of is refused, which is exactly right, and is what the second arm shows. It is not earned against an unread option: a list the library cannot find is indistinguishable from a list you never supplied, and the library's default is to permit.
So the shape of your rule decides what happens when a value is unrecognised, and the ownership of the option name decides what happens when the rule itself goes missing. They are separate questions and this file answers both, one line apart.
The declaration flag, and a refusal that says the paperwork was fine
A required declaration sits in front of the carrier's own check, and losing either one is enough. Renaming requireDeclaration accepted the mislabelled parcel -- contents class 8, paperwork class 9. Renaming declarationCheck accepted it too. Renaming both is byte-identical to renaming the flag alone, because the flag is consulted first and the check behind it is never reached. In the flag arm the configuration still reports caller-supplied(checks the declaration against the shipper record), which is accurate: the function is present, bound, correct, and never called.
Renaming approved, the single field the check returns its verdict in, fails the other way and produces the strangest line in the file. Every parcel is refused with declaration-rejected(declaration-matches-shipper-record). The reason string asserts the opposite of the outcome, because the reason survived the rename and the verdict did not.
That inversion is worth memorising as a triage signature. A refusal whose stated reason describes a success almost always means a caller-supplied guard ran correctly and the library read its answer out of a field whose name has moved.
What protection alone did, and what to do about it
Nothing. The file was protected on five profiles -- the ES5 default, the modern target, both emit-gate configurations and the string-encoding profile -- and produced byte-identical output on all five. Every acceptance and every refusal matched exactly. Renaming identifiers and encoding strings does not disturb a screening engine.
Every result above required member renaming, which is off by default and needs a regular expression naming the members it may rewrite. Keep that pattern anchored to names your own code owns on both sides. A dangerous-goods configuration is the opposite of that: your file writes the keys, the parcel fields and the callback's return shape, and installed code reads all three.
Then assert refusals, not just acceptances. Take the fireworks parcel and assert that it is refused and that the reason is the one you expect; take the correctly declared paint and assert that it is accepted. A suite that only checks legitimate parcels go through passes in every arm above, including the one that put class 1.4 explosives on a truck.
Frequently asked questions
Does obfuscating a logistics bundle change which parcels are accepted?
Not by itself. The sample was protected on five profiles including the ES5 default, the modern target and string encoding, and produced identical decisions on all five. Every result in this article required member renaming, which is off unless you enable it and supply a pattern.
If two separate rules refuse the same parcel, is one of them redundant?
Not against most failures, but they are not independent of renaming. Each rule alone still refused the fireworks and changed only the reason string. One pattern naming both keys accepted class 1.4 explosives for road carriage, because both keys live on the same options object in the same file.
Why is a change to only the reason string worth investigating?
Because it is the leading indicator of the worst case in the file. In this measurement, and in four previous passes in this series, the arms that moved nothing but a label were exactly the halves whose combination admitted the thing the rules existed to stop.
Do allowlists fail closed under obfuscation?
Against an unrecognised value, yes: renaming the hazard-class field refused every parcel, including legitimate ones. Against a lost option name, no: renaming the option that carries the allowlist accepted a lithium battery for air carriage, because a list the library cannot read is the same as one you never supplied.
What does a refusal reason that describes a success mean?
It usually means a caller-supplied guard ran correctly and the library read its verdict from a field whose name has moved. This sample printed declaration-rejected(declaration-matches-shipper-record) on every parcel. Treat that inversion as a rename signature rather than a logic bug.
Why did renaming a required-declaration flag matter more than the check behind it?
Because the flag is consulted first. With the flag gone the strict check is never reached, so the combined result is identical to losing the flag alone, and the configuration still reports the strict check as present and correct.
What should a test suite assert here?
Refusals as well as acceptances, with their reasons. Assert that the prohibited parcel is refused and why, and that the compliant parcel is accepted. Testing only that legitimate shipments succeed passes in every arm in this article.
Related reading