Clinical safety
Published
No, not on its own. We protected a prescribing-safety module on five presets and every order it screened came out with the same verdict. Then we renamed the members it reads, and an order for a drug the patient has had anaphylaxis to was transmitted to the pharmacy.
What we actually measured
An e-prescribing screen decides whether an order reaches the pharmacy. The module we built for this article is the shape those screens really have: a table of interacting pairs with a severity grade on each, a cross-reactivity map for allergies, a floor that says which grade is severe enough to block, an allowlist of acceptable override reasons, and two callbacks the hospital wired in because the declarative rules were too coarse on their own.
The engine that consumes all of that is a separate installed library. We copied it in unprotected, exactly as a real dependency arrives, and protected only the file the team owns. That split is the whole point: a rename moves the names in your code and cannot move the names inside a package you did not rebuild.
First the baseline. We protected the module on five presets covering both the ES5 and the modern targets, plus the string transforms, and ran every order through each build. Twenty-five comparisons, twenty-five identical outputs. Protection alone changed nothing about which prescriptions were blocked. If you take one thing from this page, take that: the transform is not the hazard.
The hazard is renaming, and it starts by looking cosmetic
Member renaming is a different setting. It rewrites property names in the code you protect, and any name that has to survive a boundary is at risk: a key another library reads, a field a downstream parser matches by text, a flag a callback hands back.
This module refuses a cephalosporin for a penicillin-allergic patient twice on purpose. The declarative cross-reactivity map catches it, and the callback the hospital added catches it too. That is defence in depth, and it is the pattern that hides the worst result in the file.
Renaming just the map produced this. The order is still refused. Only the sentence changed:
cross-reactive(penicillin -> cefazolin) became allergy-contraindicated(documented-anaphylaxis(penicillin -> cefazolin)).
A reviewer looking at that diff sees a wording change in a refusal that still happens, and closes it. It is the fifth consecutive time in this series that the half which moves only a label has turned out to be the leading indicator for the worst cell in the same file.
What the second layer was actually holding
Renaming only the callback told a different story, because the two layers do not cover the same drugs. The map lists cefazolin and cephalexin. The callback also knows that amoxicillin is a penicillin, which no cross-reactivity table needs to say because amoxicillin is not cross-reactive with penicillin. It is one.
With the callback renamed, the cephalosporin was still refused by the map. The amoxicillin order went through:
amoxicillin-for-penicillin-allergy DISPENSED=true dispensed(amoxicillin formulary=2026.1)
That is a patient with a documented anaphylactic reaction being handed the drug class that caused it, from a screen that reported itself configured and running.
With both renamed, both orders were dispensed. The lesson is not that two layers are worse than one. It is that two layers are only two layers if one of them lives somewhere the transformation cannot reach, and both of these lived in the same file.
The two callbacks failed in opposite directions
The module has two caller-supplied guards, and we phrased their return values differently on purpose. The interaction checker hands back a permission, a field called safe. The allergy screen hands back a prohibition, a field called contraindicated. Everything else about them is the same.
Rename the permission and every single order is refused, including the entirely unremarkable statin. The library reads an undefined field, treats the missing permission as a refusal, and prints the guard's own reason for approving:
unremarkable-order DISPENSED=false screening-refused(no-class-conflict)
Rename the prohibition and the refusal disappears instead. A missing prohibition permits.
Same file, same mechanism, same transformation, opposite outcomes, decided purely by how the flag was worded. The permission version is loud and stops the clinic within the hour. The prohibition version is silent and ships. Write the flag your collaborator hands back as a permission, and this failure mode costs you an outage instead of a patient.
A refusal whose reason says it approved
That output line is worth naming, because it is the fastest diagnosis available when this arrives as a support ticket rather than as a measurement.
A guard that refuses while its own explanation asserts success is not a logic bug. It is a lost return-shape field. The guard ran, decided correctly and wrote its reasoning into a field that still arrives; only the verdict field moved, so the caller defaults to refusing and prints the reason for allowing.
This series has now seen that signature in six consecutive passes across unrelated domains. If a clinician tells you the system refused an order and the message underneath says the check passed, stop looking at the rule and start looking at what renamed the field the caller reads.
The severity floor, and why the option key behaves differently
The floor deserves separate treatment because it shows the split that matters most when you are deciding what to test.
The configured floor is grade three. The library's own default is grade five, because a vendor that cannot know your clinical posture will not block on your behalf. When we renamed the option key carrying the floor, the key did not go missing in the way a value goes missing. The library simply substituted its default, and a grade-four interaction between warfarin and an NSAID stopped blocking.
The severity numbers on the table entries are a different kind of name. Those are data your code owns, and they really can become unreadable. When they did, every comparison against them was false and nothing in the table blocked at all.
So the question to ask about any number in a configuration is not how important it sounds. It is whether a vendor default will quietly stand in for it, and if so, which way that default points.
What to do about it
None of this argues against protecting clinical JavaScript. The baseline measurement says protection is behaviour-preserving here, and the reasons to protect a browser-side clinical application have not changed.
It argues for scoping the rename. Member renaming is opt-in and takes a regular expression; the correct pattern is one that matches the names your own code invented and nothing that crosses a boundary. Option keys handed to an installed library, fields your callbacks return, and keys that a rule elsewhere refers to by text all cross a boundary.
Then test the halves rather than the union. The combined arm in this file is the one that dispenses both drugs, and it is also the one a realistic broad pattern would produce. The arm that would have told you something was wrong while it was still cheap is the one that changed only a sentence.
Frequently asked questions
Does obfuscating a prescribing module change which orders it blocks?
Not by itself. We protected the module on five presets covering the ES5 target, the modern target and the string transforms, and compared every order against the unprotected run. All twenty-five comparisons were identical. The behaviour changes only once member renaming is switched on and allowed to reach names that cross a boundary.
Which names are actually at risk?
Any name that has to be recognised by something you did not rebuild. That means option keys you pass to an installed library, the fields your own callbacks hand back to it, keys that a denylist or an allowlist refers to as text, and fields on records that arrive as parsed JSON. Names that stay inside your own code are safe because both sides move together.
Why did renaming one allergy layer look harmless?
Because the other layer caught the same order and refused it, so the outcome did not change and only the reason text moved. That is exactly what makes it dangerous. The two layers covered overlapping but different drug sets, so the second layer was silently the only thing standing between one patient and a drug they had reacted to.
Is defence in depth still worth having here?
Yes, but placement decides whether it survives. Two controls in the same protected file fall to the same regular expression. A second layer that lives on the server, or in a service the browser calls, is not reachable by a transformation applied to the bundle, and that is the difference between two layers and one layer written twice.
What is the quickest way to spot this in production?
Look for a refusal whose stated reason describes success, such as a blocked order explaining that no conflict was found. That combination is a lost return-shape field rather than a rule that changed, and it points straight at the renamed name instead of at the clinical logic.
Should a caller-supplied guard return a permission or a prohibition?
A permission. When the field carrying the verdict goes missing, a missing permission reads as false and the system refuses, which is loud and recoverable. A missing prohibition reads as false too, which means it permits. The wording costs nothing at design time and decides which way the failure points.
How much harder does obfuscation make a prescribing rule to read?
Meaningfully harder for casual inspection and for automated scraping of your clinical logic, which is the realistic goal. It does not put the rules beyond a determined analyst with a debugger, because the code still has to run in the browser. Treat it as raising cost, and keep any decision you cannot afford to have altered on the server.
Related reading