What Should a Security Check Do When It Crashes?
A guard that throws is neither fail-open nor fail-closed. It is the absence of a decision. Working through that question properly took an architecture decision record, a registry, and a rule about failure paths we did not have before.
A governance SDK sits in the path of every model call, so the first promise it makes is that it will not break the application it is protecting. The second is that it enforces policy. Occasionally those two promises collide, and the interesting question is what happens then.
Not "what happens when the backend is down" - that one is easy, you cache rules and keep enforcing. The harder question: what happens when the checking code itself throws an exception?
Neither open nor closed
The two answers everyone reaches for are fail-open, meaning allow the call and record that enforcement was lost, and fail-closed, meaning block. Both are defensible. Mature telemetry SDKs swallow every internal error and never block, which is the right posture when the SDK's job is to observe rather than to enforce. Some scanning libraries have no error channel at all, so an exception propagates and the effect is fail-closed by accident.
What we found in our own pre-call pipeline was a third thing, which is worse than either: eight detector layers with no enclosing guard. An exception inside the PII scan, the canary check, the de-obfuscation pass, multi-turn injection scoring, the policy floor, the rules engine, session taint, or the tool-result scan did not resolve to allow, and did not resolve to block. It propagated into the calling application.
That is not a failure posture. It is the absence of a decision, and it violated the first promise. We recorded it honestly in our own security documentation while it was true, which is a strange thing to publish and the right thing to do, and then closed it.
The rule is not uniform, and finding out why took a stop
The obvious fix is to wrap everything and resolve by the configured fail mode. That is wrong for two of the eight layers, and the reason is worth explaining because it generalizes.
A policy floor is the non-overridable baseline. Its entire purpose is to be the thing customer configuration cannot weaken. Declaring that it resolves by fail mode makes its behavior a function of the setting it exists to be immune from. Our own codebase had already answered this elsewhere: a floor rule with a redact action fails closed to a block, because the SDK has no span-level redaction for an arbitrary rule match and must never forward content it cannot guarantee was redacted.
Read that reasoning carefully and it is not about floors. It is about forwarding. A crashed floor is the strongest possible case of "cannot guarantee." So the floor class fails closed, and the other six resolve by fail mode. One rule with one principled exception, rather than one rule applied uniformly and wrongly.
The third phase nobody had scoped
Then the same question arrives in a phase the original decision never contemplated: what if the detector decided correctly and the redaction throws?
This is not the same problem. A detection failure means we do not know whether sensitive content is present, and failing open is a bounded, accepted risk. An application failure means we know it is present, policy said remove it, and the removal did not happen. Failing open there means transmitting to a third-party provider exactly the content the SDK was instructed to strip, and, worse, the audit event would record a redaction that never occurred. A partially-redacted request that reads downstream as a successful one is the worst available outcome, because it is wrong in a way nobody will notice.
So that case fails closed. The request has not been sent yet, so blocking genuinely prevents the disclosure, which is precisely the distinction from the response phase: once the provider has answered, blocking cannot undo an answer that already exists, so nothing is withheld from the caller and only the stored audit copy fails closed, replaced by a marker that says the content was never scanned rather than persisting content nothing vetted.
What the process taught us
Two things, both of which changed how we work rather than just what shipped.
Enumerate call sites, do not read functions. We scoped the work by naming the functions we believed ran each detector. That estimate was wrong five times in a row. Grepping each layer's exported symbol instead of reasoning about which functions contained it found post-call sites the first scope missed, four more the second missed, an entire integration path nobody had listed, and a class of code we had not considered at all: the places where the same modules run to apply a decision rather than make one. The last one mattered most, because declaring a layer guarded while half its code is unguarded makes the declaration false.
Nothing on a failure path may re-run the code that failed. Every failure resolver we wrote recomputed the policy version by hashing the same ruleset a detector had just choked on. A malformed rule therefore threw from inside the handler that existed to contain it. Tests found it; reading did not. It is now a written principle rather than a comment in a catch block.
Every layer's posture for every failure state now lives in one table per language, pinned by a shared conformance fixture, and a detector that ships without a declared disposition turns a test red. The guarantee is that an exception inside a detector never reaches the host application as an unhandled error. One path remains unguarded, in the audit sender's serialization of host-supplied metadata, and it is named in our security documentation rather than left for someone to discover. It has its own fix and its own posture decision.
An unqualified claim that we do not yet fully earn is worth less than a qualified one that is true.