Clinical safety
Published
No, not on its own. We protected a chart-access module on five presets and every request was decided identically. Then we renamed the members it reads, and a sealed record opened without consent, a withheld HIV status left on a billing screen, and an emergency override stopped appearing in the trail that exists to review it.
What the module decides
A clinical portal decides two things when somebody asks for a chart: whether it opens, and which fields come back. Ours enforces a purpose allowlist, a care-team roster callback, a seal on records marked restricted with a consent check behind it, a minimum-necessary list of fields to withhold for non-treatment purposes, and a break-glass override that writes to a reviewed trail.
The engine is an installed library, copied in unprotected. Only the file the team owns was protected.
The baseline is the same as everywhere else in this series. Five presets, every request, twenty-five comparisons, all identical. Protection did not change a single access decision or a single returned field.
A sealed record that stopped being sealed
Records marked restricted are sealed. A sealed record needs explicit consent even from a clinician who is on the care team, which is how an adolescent record or a record under a disclosure restriction is handled.
That mark is a single field on the record. We renamed it, and:
sealed-record-no-consent GRANTED=true fields=[patientId,_0x1,name,allergies,hivStatus,mentalHealthNotes,substanceUseHistory]
The chart opened. Note the returned field list: the seal is visibly still there, under a new name, sitting in the payload as an ordinary data field. Nothing was deleted. The record simply stopped being recognised as restricted, so the consent gate was never reached.
A prohibition flag that goes missing is not a rule with a weaker default. It is the absence of the rule, and the engine reports a successful, well-formed, logged grant because as far as it can tell this was an ordinary chart.
The minimum-necessary list, broken from both ends
The withholding rule is a list of field names to strip when the purpose is not direct care. It is a list of strings compared against the keys of the record. We broke it twice, from opposite directions, and got the same leak.
Renaming the option key that carries the list made the library substitute its default, which is to withhold nothing. The billing screen received all three sensitive fields.
Renaming the record keys instead left the list configured, enabled and running over every request. The system still reported withheld=hivStatus/mentalHealthNotes/substanceUseHistory. And the billing screen received:
fields=[patientId,name,allergies,_0x1,_0x2,_0x3]
The rule is a list of text. The keys are names in code. Renaming rewrites names and never rewrites the text a rule is written in, so the guard keeps running, keeps reporting itself installed, and matches nothing. The values are all still there under names nobody is looking for.
This is the argument for an allowlist over a denylist, and it is a shape argument rather than a scope one. A list of fields you will return fails closed when a name moves. A list of fields you will withhold fails open.
Two layers that fell together
A billing clerk requesting a chart for research is refused twice on purpose: research is not on the purpose allowlist, and the clerk is not on the roster.
Renaming the allowlist alone left the request refused and moved only the reason, from purpose-not-permitted to not-on-care-team. The kind of diff a reviewer closes.
Renaming the roster callback alone left the clerk refused, but granted an unassigned clinician the full chart including all three sensitive fields.
Renaming both granted the clerk the record for research.
The override that stopped being an override
The roster arm produced something this series has not seen before, and it is worth separating out.
Break-glass exists so that an unassigned clinician can reach a chart in an emergency, on the understanding that the access is loud afterwards. In the unprotected run, the emergency request was refused by the roster, escalated to break-glass, granted, and written to the trail.
With the roster callback renamed, the same request was simply granted. It was never refused, so it never became an override, so nothing was written:
override-trail=(EMPTY)
The access that was previously the most closely reviewed event in the system became the least. Losing the guard also erased the audit record of bypassing it, because only refusals trigger the trail. Guard and alarm in one edit, and by a route that does not touch the logging code at all.
Renaming the audit sink itself gets to the same place more directly: the override still works, the configuration still reports the sink as caller-supplied and writing to the reviewed trail, and the trail is empty because the library substituted its own builtin.
The consent gate
The consent check behaved exactly as an enabling flag in front of a strict control does. Renaming the flag, renaming the verifier, or renaming both all opened the sealed record, and the union was identical to the flag alone.
The verifier's return field is worded as a permission, and losing it refused every sealed record including the properly consented one. Loud, and the better failure.
Both the seal on the record and the flag enabling the check reach the same outcome by different routes, which means a review that confirms consent checking is configured has answered a true question that was not the one that mattered.
What to do about it
Scope member renaming to names your own code invented, and treat record keys that a rule refers to by text as boundary-crossing even though they never leave the process. The rule and the key are the same string held in two different kinds of place, and only one of them moves.
Prefer allowlists to denylists wherever a guard is expressed as a list of field names, because that decides which way a lost name fails. Word the flags your callbacks return as permissions for the same reason.
And do not rely on a control being reported as configured. In three separate arms here the system accurately described a guard that was present, bound, correct and never consulted.
Frequently asked questions
Does obfuscating a chart-access module change who can open a record?
Not by itself. We protected the module on five presets covering the ES5 target, the modern target and the string transforms, and every access decision and returned field list matched the unprotected run. Twenty-five comparisons, all identical. Decisions moved only under member renaming.
How did a sealed record open without consent?
The seal is a flag on the record. Renaming it meant the engine no longer recognised the record as restricted, so the consent gate was never reached and the chart opened as an ordinary record. The flag was still present in the payload under its new name, which is why nothing anywhere reported a missing field.
Why did the withheld fields still leave the system?
The withholding rule is a list of strings compared against the record's keys. Renaming the keys leaves the list configured and running while it matches nothing, so the fields are copied through under their new names. The system continued to report the three fields as withheld throughout.
Would an allowlist have behaved differently?
Yes, and that is the practical takeaway. A list of fields you will return fails closed when a name moves, because an unrecognised key is simply not on the list and does not go out. A list of fields you will withhold fails open, because an unrecognised key does not match and is not stripped.
What happened to the break-glass audit trail?
Two separate renames emptied it. Renaming the audit sink made the library fall back to its own builtin, which writes to a buffer nobody reads, while still reporting the sink as caller-supplied. Renaming the roster check was subtler: the emergency request was granted outright rather than refused, so it never became an override and nothing was recorded.
Is it enough to confirm that consent checking is enabled?
No. In our measurement the configuration accurately reported a consent verifier that was present, correctly bound and never invoked, because the flag in front of it had been lost. A control's own telemetry cannot tell you it was skipped when the telemetry is downstream of the thing that skipped it.
How much harder does obfuscation make access rules to study?
It raises the cost of reading your access logic out of a bundle, which is a reasonable objective for a browser-side clinical application. It does not make the rules unreadable to a determined analyst, and it is not a reason to move authorisation decisions off the server, which remains where they belong.
Related reading