Chapter 3 of 8 · 14 min read
Capturing the filter verdict
On this page
A content filter does not return a generic failure. It names the policy dimension that fired and, on most providers, the severity band it landed in, and it does so in a structured body next to a correlation id. That verdict is the only account anyone will ever give you of why a request failed, because the score behind it is not exposed and the provider will not decompose it on request.
The reason such a body exists is not that the provider is being generous with diagnostics. It falls out of how the classifier works. A content filter does not answer one question, it answers several at once — one score per policy category — and a block happens when at least one of those scores clears its threshold. So at the moment the request is refused, the provider is holding a small table of per-category results, and the error body is that table written down. This is why the field the response gives you is a category rather than a reason, and why it arrives whether or not anybody has ever asked for it. Reading the body is reading the classifier's own output, and it is the closest to the mechanism you will ever get from outside.
Most services throw it away. The usual shape is an HTTP client that catches the exception, stringifies it into a log message, and discards the response body, the headers and the correlation id — leaving a log line the service made up in place of the one the provider sent. This chapter is about capturing that signal instead: where to do it, what fields are load-bearing, how the same verdict is expressed differently on four platforms, and how to reproduce a block afterwards without handling the content that caused it.
The signal the provider already sent you
Here is a complete input-stage verdict from one platform, of the kind a clinical triage assistant receives when a structured risk assessment trips a harm category:
{
"error": {
"code": "content_filter",
"message": "The response was filtered due to the prompt triggering the content management policy.",
"param": "prompt",
"innererror": {
"code": "ResponsibleAIPolicyViolation",
"content_filter_result": {
"hate": { "filtered": false, "severity": "safe" },
"self_harm": { "filtered": true, "severity": "medium" },
"sexual": { "filtered": false, "severity": "safe" },
"violence": { "filtered": false, "severity": "low" }
}
}
}
}Alongside it, headers carry a request id —
apim-request-id: 4f0a2c3e-8b17-4d2a-9e55-1c8f0b6d7a91 — and a model deployment
name. Read together, those eliminate most hypotheses in about ninety seconds.
The self_harm category fired at medium severity on the prompt, not the
completion, which the param field states outright. Nothing else fired. It is
not a jailbreak detector, not a violence classifier reacting to clinical
language about harm to others, not an output-side block on something the model
wrote, and not a malformed request.
Each of those four is ruled out by a different piece of the body, and it is
worth seeing which, because the same reading works on verdicts that look nothing
like this one. param says prompt, which places the decision before the
model and rules out anything the model wrote. The absence of a jailbreak entry
rules out the prompt-injection detector. violence is reported and did not
fire, which rules out the theory that clinical language about harm is being read
as violent. And the presence of a content_filter_result object at all rules
out a malformed request, because a request that failed validation never reached
a classifier and comes back with a different error code entirely.
Two of those four eliminations rest on something the body does that is easy to
walk past: it reports every category, including the ones that did not fire.
Negative evidence is only available when absence is stated, and a body listing
only the category that blocked would have left the violence hypothesis alive.
The distinction between safe and low in that listing carries the same kind
of information. violence came back low rather than safe, which says the
classifier did find something in a clinical note about ending one's life that
scores mildly on violence, and that it was nowhere near the configured
threshold. That is a fact about how close this workload sits to a second
boundary, and it costs nothing to have noticed it now rather than on the day the
threshold moves.
It also reframes what kind of problem this is. Where the category that fired is the category the product is about — a clinical triage tool exists to process self-harm risk assessments — the block is not a prompt bug to be worked around by rephrasing. It is a mismatch between the deployment's policy configuration and the workload, and the fix, whatever it turns out to be, involves the provider.
Capture at the boundary, once, in a shape you can query
The instinct after a failure like that is to turn up logging everywhere. That is the wrong shape of fix, partly because volume is not the problem and partly because the content you most want is content you are least allowed to store.
Volume is not the problem because the loss is not gradual. A provider's error
arrives as an exception object holding a response, and that response holds the
body, the headers and the status. The moment any code calls str() on the
exception and keeps only the result, everything that was not in the exception's
own message is gone — not hard to find, gone, because the object it hung off has
been dropped and the request will not be sent again. Every log statement after
that point is downstream of the destruction, so turning ten of them into a
hundred adds a hundred copies of a string that never contained the category.
This is the property that decides where the capture belongs: it has to happen
above the first place the object is flattened, and there is exactly one such
place worth choosing.
Do the capture in one place: the adapter that wraps the provider call, meaning
the single module that builds provider-shaped requests and turns provider-shaped
responses back into your own types. Every provider call passes through it, it is
the only code that understands provider-specific error bodies, and it is the
natural place to translate them into the taxonomy from the previous chapter.
Everything downstream then sees a PolicyBlock carrying fields it can query
rather than a string it has to parse.
FILTER_FIELDS = ("hate", "self_harm", "sexual", "violence",
"jailbreak", "protected_material_text", "protected_material_code")
def extract_filter_signal(exc) -> dict:
body = exc.response.json().get("error", {})
inner = body.get("innererror", {})
results = inner.get("content_filter_result", {})
fired = {k: v.get("severity", "detected")
for k, v in results.items()
if isinstance(v, dict) and (v.get("filtered") or v.get("detected"))}
return {
"provider": "azure-openai",
"deployment": exc.request.url.path.split("/")[-3],
"policy_code": inner.get("code") or body.get("code"),
"stage": "input" if body.get("param") == "prompt" else "output",
"categories": sorted(fired), # ["self_harm"]
"severities": fired, # {"self_harm": "medium"}
"request_id": exc.response.headers.get("apim-request-id"),
"http_status": exc.response.status_code,
"raw_error": body.get("message"),
}Three of those fields are load-bearing in ways that are easy to miss. stage
decides whether you were billed and whether the completion ever existed, which
changes the cost of the failure by two orders of magnitude. deployment matters
because policy configuration attaches to a deployment — one named, separately
configured instance of a model that you create and call by name — rather than to
an account: two deployments of the same model in the same subscription can
enforce different thresholds, and "it works in staging" is very often "staging
has a different filter configuration". request_id is the only token the
provider's support engineers can look up on their side; without it, an appeal is
you describing symptoms.
The one thing the record must not contain is the user's text. Store a content hash and a length instead, so repeated failures on the same document group together and you can prove that ten failures were one document retried ten times rather than ten distinct documents.
That grouping works because of one property of a hash and nothing more. A hash is a function from text of any length to a fixed-length string, and equal inputs always produce equal outputs. So two records carrying the same hash were produced by the same text, and counting distinct hashes counts distinct documents — which is the only question the incident actually needed answered, and it is answerable without holding a single character of anybody's note.
The limits are worth stating in the same breath, because a hash is routinely asked to do two jobs it cannot do. It does not explain anything: it groups failures and tells you nothing about why they failed, which is why the category fields sit beside it rather than being replaced by it. And it is not anonymisation for content somebody can guess. Hashing is one-directional in the sense that you cannot read the input back out of the digest, but anyone holding a candidate text can hash it and compare, so a hash of a short, formulaic string drawn from a small set of possibilities confirms which one it was. For a free-text clinical note the grouping is safe and useful. For a field with a handful of possible values it is a lookup table with extra steps.
{
"event": "policy_block",
"ts": "2026-08-04T09:41:07.412Z",
"request_id": "4f0a2c3e-8b17-4d2a-9e55-1c8f0b6d7a91",
"trace_id": "0af7651916cd43dd8448eb211c80319c",
"reference": "RB-7QK4-22",
"provider": "azure-openai", "deployment": "gpt-4o-clinical-eu",
"endpoint": "/triage/assess", "cohort": "clinic-enterprise",
"stage": "input", "policy_code": "ResponsibleAIPolicyViolation",
"categories": ["self_harm"], "severities": {"self_harm": "medium"},
"prompt_sha256": "9c1e…a7", "prompt_chars": 1417, "attachments": 0
}reference is a short, human-typeable code shown to the user in the error card.
It is the field that collapses a four-hour ticket triage into one lookup: a user
reads eight characters over the phone, support pastes them into a search box,
and the record above appears — with the provider's request id already attached,
without anyone handling the original content.
The same signal wears different clothes on every platform
The extractor above is provider-specific by necessity, because no two platforms express the verdict the same way. A service calling more than one provider needs one extractor per provider and a single internal shape they all produce, and it is worth writing that mapping down before you need it at 09:41.
| Provider | Where the verdict lives | Category vocabulary | Severity information | Correlation id |
|---|---|---|---|---|
| Azure OpenAI | 400 body innererror.content_filter_result, or finish_reason: content_filter with content_filter_results per choice | hate, self_harm, sexual, violence, plus jailbreak and protected-material detectors, the latter flagging text or code reproduced from copyrighted sources | Four bands: safe, low, medium, high | apim-request-id header |
| Anthropic API | 200 with stop_reason: "refusal" and a stop_details object | Open set, e.g. cyber, bio, reasoning_extraction | None exposed | request-id header, id on the message |
| Google Gemini | promptFeedback.blockReason on input; candidates[].finishReason on output | HARM_CATEGORY_* constants | Probability bands rather than severity | Response metadata |
| Amazon Bedrock Guardrails | stopReason: guardrail_intervened plus a trace with per-policy assessments | Your own configured topics and filters, alongside managed ones | Per-policy confidence | Invocation id |
Three structural differences fall out of that table and they change what your
record can hold. Some platforms return a severity band and some return nothing
but a category, so a normalised schema needs severity to be nullable rather than
defaulted — a missing severity written as low will silently corrupt every
comparison you make later. Some deliver the verdict as an error and some as a
successful response, which means the extraction has to run on both the exception
path and the success path. And on a guardrails-style platform the categories are
partly yours, configured by whoever set the guardrail up, which makes the same
field simultaneously a provider signal and a piece of your own configuration.
Severity bands deserve one caution. A band is a bucket over a continuous score,
and the block decision is the band compared against a configured threshold. Two
implications follow: a document at the top of low and one at the bottom of
medium are nearly identical inputs with opposite outcomes, and a provider
recalibrating where the band boundaries sit changes your block rate without
changing any policy that was ever announced. Record the band you were given and
resist reading it as a measure of how bad the content was.
Reproducing the block without real user content
Confirming the mechanism means sending something. The reproduction question is not "can I make the assistant fail", it is "can I make the same category fire at the same stage on wording that matches what users actually type".
Start from the structure of the failing workload rather than from imagination. A clinician-facing form has fixed field labels — presenting concern, risk indicators, protective factors, plan — and those labels are yours, not patient data. Compose a probe — a request sent purely to observe the verdict, whose answer nobody intends to use — out of the real scaffolding and synthetic content in the same register, and send it through the same adapter so the same code path produces the same signal:
PROBE = """Structured risk assessment. Complete the clinical summary.
Presenting concern: patient reports persistent thoughts of ending their life,
present most days for three weeks.
Risk indicators: expressed intent, has considered method, recent bereavement.
Protective factors: lives with partner, engaged with services, no prior attempt.
Plan: assess need for same-day crisis referral."""
for label, text in [("full", PROBE), ("no_indicators", strip_section(PROBE, "Risk indicators"))]:
r = adapter.call(deployment="gpt-4o-clinical-eu", prompt=text)
print(label, r.blocked, r.categories, r.severities, r.request_id)
# full True ['self_harm'] {'self_harm': 'medium'} 6b21…
# no_indicators True ['self_harm'] {'self_harm': 'medium'} 8c04…Two probes, both blocked, both on self_harm at medium, both on input. The
mechanism is confirmed, the reproduction contains no patient data, and it took
under a minute. Run the same probe against a second deployment before concluding
anything about policy: if it passes on a deployment configured with a higher
self-harm threshold, the input is at the boundary rather than deep inside the
blocked region, and the configuration rather than the wording decides the
outcome. If it fails everywhere, no realistic threshold saves you and the
conversation moves from configuration to exemption.
What that second deployment buys is a measurement of something the provider never shows you, and the inference is worth spelling out because it is reusable. The classifier produces a score you cannot read; the verdict is that score compared against a threshold you can partly choose. Send identical bytes to two deployments that differ only in threshold and the score is the same in both, since it is a function of the input. So a block on the stricter deployment and a pass on the looser one places the score between the two thresholds. A block on both places it at or above the higher one. You have not seen the number, and you have bracketed it, which is enough to decide whether configuration or content is the thing standing in the way.
The inference holds only while the two deployments differ in configuration and in nothing else. Different regions, different model versions and different classifier versions each break the assumption that the score is the same on both sides, and when they do, a pass on the second deployment is evidence about the second deployment rather than about your input. Note down which of the two you have before you carry the result into a support ticket, because a provider engineer will ask, and "we tried another deployment and it worked" without that detail is not a finding they can use.
A category is not an explanation
Capturing the category tells you which classifier fired. It does not tell you why that classifier scored the text the way it did, because the score is not explainable in the response and the provider will not decompose it for you. You get a category and a band, not a highlighted span.
It also does not tell you whether anything changed. A self_harm: medium
verdict today is consistent with a policy in place since the deployment was
created and with a threshold that moved last night; a single observation cannot
distinguish them. That takes a continuous measurement, which a later chapter
builds. What the capture provides is the label that measurement needs — you
cannot segment by policy category if you never recorded one.
Category names are also provider-local: self_harm on one platform,
HARM_CATEGORY_DANGEROUS_CONTENT on another, a stop_details.category of bio
or cyber on a third, a named guardrail policy on a fourth. Store the raw
provider string rather than mapping it into a normalised enum on the way in,
because the mapping is lossy and because the string is what you will quote in an
appeal. Normalise later, in the query layer, where you can change your mind.
What the capture buys, then, is narrow and decisive: it converts a failure nobody can characterise into a failure with a category, a stage, a severity, a deployment and an identifier the provider recognises. That is enough to route the problem to the right team, to rule out four hypotheses in a minute, and to start a conversation with the vendor. It is not enough to hold a conversation with them, because a probe is not evidence — it shows that text in this shape trips the category, it does not isolate which part of the input is doing the work, it is longer than it needs to be, and it is not in a form you can attach to a support ticket or re-run as a check the next time policy moves. Getting from a probe to that artefact means cutting it down, carefully, because the real failing input is a patient's note and the cutting cannot be done on the original.
Provider content filters as an operational problem — read the current version at https://daviddominguez.dev/learn/en/provider-content-filters-as-an-operational-problem/capturing-the-filter-verdict