From 0bf190c3b7efa900d790a1379e8a352cd44372d8 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 3 Sep 2026 07:33:57 +0000 Subject: [PATCH 1/2] docs(proxy): correct a stale unscannable-body claim and record the download-side ruling `crates/aisix-proxy/AGENTS.md` listed `audio.rs` and `images_edits.rs` as places that silently drop non-UTF-8 multipart `prompt` parts with `filter_map`. That has not been reachable since #1016: both surfaces call `dispatch::require_utf8_prompt_fields` after model resolution and before the guardrail chain runs, so an undecodable `prompt` part is answered 400 and the `filter_map` in their scan builders can no longer see one. The file is what agents read instead of the code, so the wrong sentence propagates; it is replaced with an explicit note that those two surfaces are not in that set. The same paragraph called the lossy-and-unrefused asymmetry "#1022's open product decision". #1022 is closed: the upload side now refuses under a text purpose (#1113/#1114/#1115), and the download side deliberately does not. That ruling is recorded as its own constraint next to the jobs/files rules, and the stale "needs a product decision" note on `jobs::scan_output_blob`'s doc comment is updated to match. Also checked against current code and left unchanged because they still hold: the `jobs::scan_output_blob` / binary-purpose `scan_input_blob` / `passthrough_route` lossy-scan claims, the four `record_unevaluable_*` sites, the `NO-GUARDRAIL-CHAIN:` marker and its parsing test, `usage_attr::bypass_reason`, `bypass_tag()` / `bounded_failure_tag`, `chat.rs`'s `UpstreamCharge.bypass_reason`, and the `varchar(64)` width of the control plane's `guardrail_bypassed_reason`. No behaviour change. --- crates/aisix-proxy/AGENTS.md | 41 +++++++++++++++++++++++++--------- crates/aisix-proxy/src/jobs.rs | 5 +++-- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/crates/aisix-proxy/AGENTS.md b/crates/aisix-proxy/AGENTS.md index df241a3f8..0ad50f2b7 100644 --- a/crates/aisix-proxy/AGENTS.md +++ b/crates/aisix-proxy/AGENTS.md @@ -154,17 +154,22 @@ unscannable sites as the full set: the places that scan a mangled copy unconditionally, with no failure policy involved. `jobs::scan_output_blob` and the binary-purpose arm of `scan_input_blob` scan `String::from_utf8_lossy` and relay the original bytes whatever the row -says; `audio.rs` and `images_edits.rs` drop non-UTF-8 multipart prompt -parts with `filter_map`; `passthrough_route` scans the lossy body. None of -these consults `refuses_unevaluable_*`, so a fail-CLOSED row does not -refuse there either — that asymmetry is #1022's open product decision, not -something telemetry can paper over, and making them refuse is a behaviour -change rather than an observability one. Tagging them instead would fire -the field on every binary upload and download, which destroys the negative -answer just as thoroughly. Note that `record_unevaluable_*` is the wrong -helper at such a site: its predicate assumes the fail-closed case was -already refused, so at a site that never refuses it would silently drop -exactly the case worth reporting. +says; `passthrough_route` scans the lossy body. None of these consults +`refuses_unevaluable_*`, so a fail-CLOSED row does not refuse there either +— that asymmetry is settled rather than pending (the section below rules on +the download side), and making them refuse would be a behaviour change, not +an observability one. Tagging them instead would fire the field on every +binary upload and download, which destroys the negative answer just as +thoroughly. Note that `record_unevaluable_*` is the wrong helper at such a +site: its predicate assumes the fail-closed case was already refused, so at +a site that never refuses it would silently drop exactly the case worth +reporting. + +`audio.rs` and `images_edits.rs` are NOT in that set: since #1016 both call +`dispatch::require_utf8_prompt_fields` after model resolution and before the +chain runs, so an undecodable `prompt` part is answered 400 and the +`filter_map` in their scan builders can no longer be reached by one. Do not +describe those surfaces as silently dropping non-UTF-8 prompt parts. Values come from the guardrail kind's own `bypass_tag()` — the same bounded vocabulary `GuardrailVerdict::block_unavailable` carries, so one outage reads @@ -173,6 +178,20 @@ the proxy-raised pass. The audit log clamps whatever it is given through `bounded_failure_tag`, because the value lands on an unsanitized metric label and on a wire field the control plane stores as `varchar(64)`. +## The `/v1/files` download blob is scanned lossily and never refused, by decision + +`jobs::scan_output_blob` decodes the downloaded blob with +`String::from_utf8_lossy`, relays the provider's bytes verbatim, and does not +consult `refuses_unevaluable_output` — so no row, fail-closed or not, makes it +refuse. That is deliberate, not an oversight: a download carries arbitrary +provider-side bytes the caller did not choose the content of, so refusing an +undecodable body would reject legitimate downloads. The upload side does refuse +(#1113/#1114/#1115) because those are the caller's own bytes and its text +purposes are contractually UTF-8. + +Do not mirror the input-side `match` here, and do not report the asymmetry as a +finding or file an issue for it. + ## Every terminal path emits the access log — including the ones that give up early The access log and `request_metrics::record` are emitted **by the handler**, at diff --git a/crates/aisix-proxy/src/jobs.rs b/crates/aisix-proxy/src/jobs.rs index f9b827a5b..bd7ac5273 100644 --- a/crates/aisix-proxy/src/jobs.rs +++ b/crates/aisix-proxy/src/jobs.rs @@ -615,8 +615,9 @@ async fn scan_input_blob( /// caller never chose (whatever the provider holds under a file id), and it /// has no `purpose` to classify it by — the upload's `purpose` is what lets /// the input side tell a JSONL payload from a lawful PDF. Failing closed -/// here would refuse lawful downloads, so the direction needs a product -/// decision rather than a mirrored `match`. Tracked in #1022. +/// here would refuse lawful downloads, so this side stays lossy and +/// unrefused by decision (#1022, closed): do not mirror the input-side +/// `match` here, and do not re-file the asymmetry as a finding. #[allow(clippy::too_many_arguments)] async fn scan_output_blob( state: &ProxyState, From 518a0e00b520b1a0aa18b57de4b46d434e2cd78a Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 3 Sep 2026 07:46:05 +0000 Subject: [PATCH 2/2] docs(proxy): drop the download-side ruling, and scope the audio correction to the multipart prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes, leaving this PR as the stale-claim correction alone. Removes the `/v1/files` download-blob section and the matching edit to `jobs::scan_output_blob`'s doc comment. Whether that scan keeps its current shape is being decided, and a constraint saying "this is deliberate, do not change it" would describe code that may not exist — the same class of stale statement this PR exists to fix. `jobs.rs` is back to its state on `main`. Narrows the correction itself, which was too broad. `audio.rs` is not wholly out of the unconditional-lossy-scan set: `transcription_output_text` still falls back to `String::from_utf8_lossy(body)` for the `text` / `srt` / `vtt` response formats, consults neither `refuses_unevaluable_output` nor `record_unevaluable_output_bypass`, and the original bytes are relayed. Only the multipart `prompt` path left the set, via #1016. That output site is now listed alongside `passthrough_route`, and the correction says explicitly that it is a separate site, so the next reader does not skip `audio.rs` when auditing unscannable bodies. --- crates/aisix-proxy/AGENTS.md | 39 ++++++++++++---------------------- crates/aisix-proxy/src/jobs.rs | 5 ++--- 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/crates/aisix-proxy/AGENTS.md b/crates/aisix-proxy/AGENTS.md index 0ad50f2b7..192a53ee9 100644 --- a/crates/aisix-proxy/AGENTS.md +++ b/crates/aisix-proxy/AGENTS.md @@ -154,22 +154,25 @@ unscannable sites as the full set: the places that scan a mangled copy unconditionally, with no failure policy involved. `jobs::scan_output_blob` and the binary-purpose arm of `scan_input_blob` scan `String::from_utf8_lossy` and relay the original bytes whatever the row -says; `passthrough_route` scans the lossy body. None of these consults -`refuses_unevaluable_*`, so a fail-CLOSED row does not refuse there either -— that asymmetry is settled rather than pending (the section below rules on -the download side), and making them refuse would be a behaviour change, not -an observability one. Tagging them instead would fire the field on every -binary upload and download, which destroys the negative answer just as -thoroughly. Note that `record_unevaluable_*` is the wrong helper at such a -site: its predicate assumes the fail-closed case was already refused, so at -a site that never refuses it would silently drop exactly the case worth +says; `passthrough_route` scans the lossy body; `audio.rs`'s +`transcription_output_text` falls back to the lossy body for the plain-text +transcript formats (`text` / `srt` / `vtt`). None of these consults +`refuses_unevaluable_*`, so a fail-CLOSED row does not refuse there either — +not something telemetry can paper over, and making them refuse is a behaviour +change rather than an observability one. Tagging them instead would fire the +field on every binary upload and download, which destroys the negative answer +just as thoroughly. Note that `record_unevaluable_*` is the wrong helper at +such a site: its predicate assumes the fail-closed case was already refused, +so at a site that never refuses it would silently drop exactly the case worth reporting. -`audio.rs` and `images_edits.rs` are NOT in that set: since #1016 both call +What is no longer in that set is the multipart `prompt` on `audio.rs` and +`images_edits.rs`: since #1016 both call `dispatch::require_utf8_prompt_fields` after model resolution and before the chain runs, so an undecodable `prompt` part is answered 400 and the `filter_map` in their scan builders can no longer be reached by one. Do not -describe those surfaces as silently dropping non-UTF-8 prompt parts. +describe those surfaces as silently dropping non-UTF-8 prompt parts — audio's +transcript OUTPUT scan, listed above, is a separate site and still lossy. Values come from the guardrail kind's own `bypass_tag()` — the same bounded vocabulary `GuardrailVerdict::block_unavailable` carries, so one outage reads @@ -178,20 +181,6 @@ the proxy-raised pass. The audit log clamps whatever it is given through `bounded_failure_tag`, because the value lands on an unsanitized metric label and on a wire field the control plane stores as `varchar(64)`. -## The `/v1/files` download blob is scanned lossily and never refused, by decision - -`jobs::scan_output_blob` decodes the downloaded blob with -`String::from_utf8_lossy`, relays the provider's bytes verbatim, and does not -consult `refuses_unevaluable_output` — so no row, fail-closed or not, makes it -refuse. That is deliberate, not an oversight: a download carries arbitrary -provider-side bytes the caller did not choose the content of, so refusing an -undecodable body would reject legitimate downloads. The upload side does refuse -(#1113/#1114/#1115) because those are the caller's own bytes and its text -purposes are contractually UTF-8. - -Do not mirror the input-side `match` here, and do not report the asymmetry as a -finding or file an issue for it. - ## Every terminal path emits the access log — including the ones that give up early The access log and `request_metrics::record` are emitted **by the handler**, at diff --git a/crates/aisix-proxy/src/jobs.rs b/crates/aisix-proxy/src/jobs.rs index bd7ac5273..f9b827a5b 100644 --- a/crates/aisix-proxy/src/jobs.rs +++ b/crates/aisix-proxy/src/jobs.rs @@ -615,9 +615,8 @@ async fn scan_input_blob( /// caller never chose (whatever the provider holds under a file id), and it /// has no `purpose` to classify it by — the upload's `purpose` is what lets /// the input side tell a JSONL payload from a lawful PDF. Failing closed -/// here would refuse lawful downloads, so this side stays lossy and -/// unrefused by decision (#1022, closed): do not mirror the input-side -/// `match` here, and do not re-file the asymmetry as a finding. +/// here would refuse lawful downloads, so the direction needs a product +/// decision rather than a mirrored `match`. Tracked in #1022. #[allow(clippy::too_many_arguments)] async fn scan_output_blob( state: &ProxyState,