Chapter 4 of 8 · 15 min read
Synthetic fixtures for policy blocks
On this page
When a request is blocked by a provider content filter, the natural next step is to get hold of the input and work on it. Someone on the customer's side usually offers: they have found the exact document that failed and will send it over so engineering can debug the real thing.
Taking that offer is tempting and wrong for three separate reasons, and they are worth separating because they have different owners. The content is frequently regulated — a clinical note, a legal brief, a customer record — held under a processing agreement that almost certainly does not include "email to a vendor's support queue"; that is legal's objection. Every replay sends it back through the provider's pipeline, and on providers running abuse monitoring alongside the content filter, repeatedly submitting content that trips a policy is exactly the pattern that layer is built to detect, so you are debugging by generating a stack of policy violations attributed to your own account; that is the account health team's objection. And a long, dense document tells you nothing about which part of it the classifier reacted to; that is the engineering objection, and it survives even in a domain with no privacy constraints at all.
What you want instead is a small piece of synthetic text that trips the same category at the same stage, that can be committed to a repository, handed to a support engineer, and run in continuous integration (CI), the automated suite that runs against every change. Getting there is two steps: shrink, then substitute.
Shrink the way you would bisect a bad commit
The procedure is git bisect with the input as the search space. You have a
large object known to produce a bad result, a test that answers bad-or-good in
one call, and a monotonic-ish structure to cut through. Halve, test, keep the
half that still fails, repeat. A 1,400-character note reduces to something under
100 characters in roughly the number of steps a binary search would suggest —
eleven probes in one clinical case, a little over a minute of wall time, costing
nothing, because input-stage blocks are not billed.
The step count is not a coincidence and it is worth seeing where it comes from, because it tells you in advance whether a reduction is worth starting. Each probe removes about half of what is left, so the number of probes is the number of halvings between the size you start at and the size you stop at. Fourteen hundred characters down to tens is somewhere around ten or eleven doublings, and that is the eleven. The useful consequence is that the cost of this procedure grows with the logarithm of the input, not with the input: a document ten times longer costs three or four extra probes rather than ten times as many.
That comparison to git bisect is an analogy, and its limit is the reason the
code below is not a binary search. Bisect works because a commit history is
monotonic: there is one bad commit, everything before it is good, everything
after it is bad, and halving is safe because discarding a half can never discard
the cause. Text has no such ordering, and nothing about a document guarantees
that what the classifier reacted to is a single contiguous span. Hold the
analogy to the shape — a large object, a cheap yes-or-no oracle, repeated
narrowing — and it is exact. Carry it as far as the halving and it produces a
confident wrong answer.
The unit you cut on matters. Cutting on characters produces truncated words and syntactically broken text whose scores mean nothing. Cut on the structure the input already has: sections, then sentences, then clauses. A clinician's note with four labelled sections gives you a first pass of four probes that is more informative than any number of blind halvings.
The reason a truncated word is worthless as a probe is that it changes what you are measuring. The classifier scores the text it is given, and text cut in the middle of a word is not a shorter version of the note — it is a different kind of object, unlike anything the classifier was calibrated on. A verdict on it tells you what the filter does with broken text. Cutting on sections, sentences and clauses keeps every candidate a well-formed piece of clinical writing, so each verdict is a verdict about the register you actually care about, and the answer at the end is in a form a support engineer can read.
def still_blocked(text: str) -> bool:
r = adapter.call(deployment=DEPLOYMENT, prompt=text)
return r.blocked and "self_harm" in r.categories and r.stage == "input"
def shrink(text: str, split) -> str:
"""Greedily drop parts while the same category still fires."""
parts = split(text)
i = 0
while i < len(parts):
candidate = "".join(parts[:i] + parts[i + 1:])
if candidate.strip() and still_blocked(candidate):
parts = parts[:i] + parts[i + 1:] # part was not load-bearing
else:
i += 1 # keep it, move on
return "".join(parts)
minimal = shrink(shrink(shrink(note, by_section), by_sentence), by_clause)Greedy deletion rather than strict halving is the practical form, because it survives the case where two separate spans each independently trip the filter — a true binary search would discard one of them at the first cut and report the other as the sole cause. The greedy loop keeps everything load-bearing and drops everything that is not.
Two properties of that loop are what make its output trustworthy, and both are visible in the code above. The first is that the invariant never breaks: a part is dropped only when the candidate without it still fires the same category at the same stage, so every text the loop holds reproduces the original verdict, including the one it finishes with. The second is that it terminates, because every accepted deletion strictly shortens the list of parts and the list cannot shrink below nothing.
What it does not guarantee is worth being equally precise about, because the word "minimal" invites more than the loop delivers. When the loop finishes, no single remaining part can be removed without the verdict changing. That is a weaker statement than "no smaller text reproduces the verdict": a pair of parts might be removable together while neither is removable alone, and this loop never tries pairs. It also depends on the order the parts are visited, so a different starting order can end on a different surviving set. Neither limitation matters much for producing an artefact to attach to a ticket, and both matter a great deal if you start reading the surviving text as the cause — which is the reading the last section of this chapter takes apart.
Run it on a blocked clinical risk assessment and the result is 68 characters:
patient reports persistent thoughts of ending their life and has considered methodEverything else — the presenting-concern label, the protective factors, the
bereavement, the referral question, the patient's initials, the dates, the
medication list — was removable without changing the verdict. That is a finding
in itself, and it is the finding a support ticket needs: the block is not about
clinical context, attachments, or request shape. Two clauses of ordinary
clinical risk language, in the register every risk assessment in the country is
written in, score self_harm at medium.
Substitute until nothing real is left
The minimal case is short, but short is not safe to keep. Sixty-eight characters can still identify a patient in combination with a timestamp and a clinic, and the point of the exercise is an artefact with no provenance from a real record at all.
Substitution replaces content while preserving the two properties the classifier is reacting to: the vocabulary and the syntactic frame. Change the entities, the numbers, the dates and any incidental specifics; keep the risk verbs, the register, the sentence structure and the section labels. Then verify — this is the step people skip — that the substituted version still produces the same category at the same severity at the same stage. A synthetic case that no longer reproduces the block is not a reduced test case, it is a different piece of text.
Be clear about what that verification is for, because it does more than check your typing. Nobody outside the provider knows what the classifier keys on. The instruction to keep the risk verbs and swap the names encodes a hypothesis — that the score depends on the kind of statement being made rather than on which patient it is about — and a hypothesis is all it is. Re-running the substituted text is the experiment that tests it, and the experiment costs one probe. When the substituted version comes back with the same category at the same severity, you have evidence for the hypothesis on this input. When it does not, you have learned something sharper: one of the things you replaced was carrying the score, and the response is to put the replacements back one at a time until it fires again, rather than to call the fixture close enough.
The dotted branch has to be enforced rather than intended. The shrink loop holds the real text in memory and in its intermediate candidates; if that loop writes anything to a log, a temp file or a notebook checkpoint, it has created a second copy of regulated data in a place nobody is auditing. Run the reduction in a process that logs only lengths and verdicts, and hand only the final synthetic string across the boundary.
What comes out is a fixture, meaning a stored input paired with the verdict it is expected to produce, checked into the repository so anyone or anything can re-run it later:
# fixtures/policy/self_harm_clinical_risk_language.yaml
id: PB-2026-08-04-01
discovered: 2026-08-04
reference: RB-7QK4-22 # links to the captured block record
provider: azure-openai
deployment: gpt-4o-clinical-eu
expect:
blocked: true
stage: input
categories: [self_harm]
severity: medium
prompt: |
patient reports persistent thoughts of ending their life and has considered method
notes: >
Synthetic. Derived from a blocked clinical risk assessment by greedy
reduction, then substituted. Reproduces the same category and severity
as the original at 68 chars. No patient-derived content.Three things become possible the moment that file exists, and none of them was possible while the evidence was a screenshot. It can be attached to an appeal: a provider's support process asks what was blocked and what you expected, and a runnable 68-character example with an expected verdict is a materially stronger submission than a description, executable against their own configuration without a data-sharing agreement. It can be given to the customer, as "your workflow is blocked by the provider's self-harm classifier, here is the minimal wording that triggers it, here is the reference, here is what we have asked for", which is a support update a clinical lead can act on and which pre-empts the conversation where they conclude the problem is your software. And it can be run as a check.
@pytest.mark.provider # excluded from unit runs; nightly + pre-release
@pytest.mark.parametrize("fx", load_fixtures("fixtures/policy"))
def test_policy_fixture_still_matches_recorded_verdict(fx):
r = adapter.call(deployment=fx["deployment"], prompt=fx["prompt"])
assert r.blocked == fx["expect"]["blocked"], f"{fx['id']}: block state changed"
assert r.stage == fx["expect"]["stage"], f"{fx['id']}: stage moved"
assert r.categories == fx["expect"]["categories"],f"{fx['id']}: category set changed"
assert r.severities.get(fx["expect"]["categories"][0]) == fx["expect"]["severity"]That test inverts what an assertion normally means, and the inversion is worth making explicit before somebody triages one of its failures as an outage. In an ordinary test the input is fixed, the expected output is fixed, and the only thing free to vary is your code — so a red result implicates the code. Here the input is a committed string, the expected output is a verdict recorded on a known date, and your code contributes nothing but the request. The only variable left in the experiment is the provider's classifier. That is what makes the suite a policy monitor rather than a test: it is the same mechanism as a test, pointed at a system you do not own.
A failing assertion here is not a bug in your code. It is a notification that the provider's behaviour on a known input has changed, which is the one event you otherwise learn about from customers — and it is the only mechanism that tells you when a policy moved in your favour, which matters, because that is when you go back to the customer and reopen the workflow you disabled. Twenty fixtures run nightly against an input-stage filter cost nothing in tokens and about eight seconds of wall time; against output-stage filters they cost real money, so cap the completion length and run those on a slower cadence.
The probe loop is itself traffic somebody is watching
Eleven probes to shrink, four more to verify substitutions, and a handful of confirmations against a second deployment is around twenty deliberate policy violations submitted from your account inside two minutes. On a provider running abuse monitoring as a second layer, that is a burst with exactly the profile the layer exists to detect: repeated submissions, minor variations, same category, short interval. Nobody has ever been suspended for debugging, but the enforcement is automated in the first instance, and the appeal for an account-level action is a slower and less pleasant process than the one for a per-request block.
Three habits keep the reduction cheap in standing as well as in tokens. Run it against a deployment or project used for nothing else, so the pattern is attributable to a test surface rather than to production. Rate-limit the loop to something unhurried — one probe every two seconds costs forty seconds of wall time on a twenty-probe reduction and changes the shape of the burst entirely. And stop the moment the verdict is confirmed; the temptation to map the whole decision boundary produces hundreds of violations and a picture that will be stale next month. The same logic applies to the nightly fixture suite, which is a small, permanent, scheduled stream of policy violations from your account. Twenty fixtures a night is nothing; the same suite copied into every developer's pre-commit hook is a different volume with a different appearance.
What each kind of evidence can do
| Artefact | Can store | Can share externally | Can re-run | Identifies the category | Detects future policy change |
|---|---|---|---|---|---|
| User screenshot | Yes | Yes | No | No | No |
| Log line with the provider's signal | Yes | The fields, not the content | No | Yes | Only in aggregate |
| The original blocked input | Usually not | No | Yes, at a cost | Yes | Yes, if you could keep it |
| Minimal reduced case | Rarely — still derived | No | Yes | Yes | Yes |
| Synthetic fixture | Yes | Yes | Yes | Yes | Yes |
Only the last row scores across every column, and the two steps of this chapter are what move you into it. The row above is the one that trips people up: a minimal case derived from real data is shorter but has the same provenance, and a sixty-eight-character extract from a patient record is still an extract from a patient record. Shrinking is not anonymisation, which is why substitution is a separate step and not an optional polish.
Scores are not additive
A minimal case identifies a trigger, not the cause, and the difference bites in both directions. Filters score whole inputs, and two clauses that each pass individually can combine into a document that fails, because the classifier is reading context rather than summing per-clause scores. Greedy reduction handles the common case where one span dominates; on a genuinely interactional input it can walk you to a minimal case that reproduces while the real mechanism is a combination you have now discarded. When a reduction collapses a 6,000-token document to eight words, be a little suspicious and check that removing those eight words from the full document makes it pass.
That check is not a repeat of the reduction, and the difference between the two is the point of running it. The reduction established that the eight words are sufficient: on their own they produce the verdict. The check establishes whether they are necessary, by taking the full document, deleting only them, and seeing whether what is left still blocks. Those are separate facts and either can hold without the other. If the remainder still blocks, the eight words were one trigger among several, and a fixture built on them will flip to green one morning while the customer's real documents carry on failing. One extra probe tells you which of the two situations you are in, and the answer decides whether the fixture is evidence about the workload or evidence about eight words.
Severity bands compound the problem. Reduction can move an input from high to
medium while keeping filtered: true, so the fixture pins a different point
in the score distribution from the original. When the provider later raises the
threshold, the fixture flips to passing and the customer's real documents keep
failing. Record the severity, as the fixture above does, and treat a severity
change as a fixture failure rather than an incidental detail.
Reduction is also not mitigation. Knowing that "considered method" is load-bearing does not license anyone to instruct clinicians to write around it; a risk assessment that omits method access is a worse clinical document, and building a euphemism layer into a safety-critical product is a decision no engineer should make alone. The fixture describes the constraint; deciding what to do about it belongs elsewhere.
The artefact this chapter produces is deliberately modest: one short synthetic string, one expected verdict, one file. What it changes is that the block stops being an anecdote. It can be stored, shared, re-run and watched, which is the full set of things a screenshot cannot do, and it is the input every later conversation with the provider depends on.
It is still one input. It proves the mechanism and detects change on that input, and says nothing about how much of your traffic is affected, which cohorts, on which endpoints, or whether today's rate is normal. One user noticing is not a measurement, and four screenshots are not a trend — which is why the next question is how many, where, and compared to what.
Provider content filters as an operational problem — read the current version at https://daviddominguez.dev/learn/en/provider-content-filters-as-an-operational-problem/synthetic-fixtures-for-policy-blocks