Repository navigation
guardrails: scan batch input files per record, not as one lossy text blob #1120
Description
Single record for guardrail coverage on the files surface. The issues that led here stay closed; this is the one place the decision lives.
Where it stands
Guardrails no longer apply to /v1/files at all — neither the upload nor the download, and neither does the refusal that was briefly built on top of them. Removed in #1121.
What still scans: the JSON request bodies of /v1/batches and /v1/fine_tuning/jobs. Those are structured requests the gateway understands, not user-uploaded blobs, and both routes are now classified Enforced in the coverage census with fixtures driving them.
Why the previous shape was removed rather than kept
jobs::scan_input_blob decoded an entire uploaded file with String::from_utf8_lossy and scanned the result as one synthetic user message, while the original bytes went upstream verbatim; the download path did the same with no refusal at all. That cannot work as a guardrail. It feeds JSON syntax to keyword matchers, produces a single verdict for a file holding thousands of independent requests, cannot rewrite anything, and on a binary upload inspects a soup of replacement characters — all while the published documentation told customers the surface was covered.
A scan that cannot do its job is worse than no scan, because the documentation is what people act on. Removing it and saying so is honest; leaving it and saying it works is not.
The refusal added on top (a 422 on a text-purpose upload that was not valid UTF-8) went with it. It never appeared in a release tag, so no shipped gateway ever had it.
The right shape, if a customer ever needs this
A batch input file is not a document — it is a batch of deferred LLM requests, one per JSONL record. So the scan should decompose it: parse each record, resolve its call type from the record's target route, falling back to the body shape; run the ordinary request guardrail chain for that call type; then act per record — redact a masked record in place, drop a blocked one, and re-serialize the file that is actually forwarded. A record whose call type cannot be resolved is the one case that should fail the whole upload, since the alternative is forwarding a request nobody classified.
That gives what a blob scan cannot: per-record verdicts, real masking, and no false hits on JSON punctuation.
It is a feature rather than a fix, and it raises its own questions — how a rewritten file is billed and audited, and what the response reports about dropped records — so it deserves its own design rather than being answered in passing.
Scope boundaries when it is picked up
- Only
purposevalues whose payload is contractually structured text. - Binary purposes stay out of guardrail scope permanently. There is no text to scan.
- File download stays out permanently. A download carries provider-side bytes the caller never authored, so refusing on an undecodable one would reject legitimate downloads.
Operational consequence to be aware of
A /v1/files usage event is now byte-for-byte identical to a screened one: no enforced hit, and no guardrail_bypassed_reason — correct, because the chain is never resolved and nothing was bypassed, but it means an operator who attaches a fail-closed guardrail gets no signal from telemetry that uploads are not covered. Documentation is the only channel that carries this.
Activity
fix(guardrails): remove the whole-blob scan from the files surface (#…