fix(gateway): anchor egress on span positions, not re-serialized values (OX-H4) - #39
Merged
Conversation
Audit OX-H4, DECISIONS §65. The highest-value finding in oxaudit.md and the first Lane B item. Egress splices replacements into the caller's raw bytes rather than re-serializing (invariant 9), and located each message by searching for JSON.stringify(text) -- where text came from flattenMessageContent, which sends every non-string content through JSON.stringify. For `content: null` that yields the four-character string `null`, so the search string became `"null"` WITH quotes, absent where the body holds a bare null. spliceIntoRawBody returns undefined on the FIRST miss, so one unmatchable message discarded the replacements for every other message in the payload. `content: null` is the standard OpenAI assistant tool-call shape, so essentially every agentic OpenAI conversation carried one. Measured on a three-times-repeated block the Gateway does save on: all-string control 8,685 sent -> saving lands one content: null 8,685 sent -> 8,685 forwarded, saving gone one array content 8,530 sent -> 8,530 forwarded, saving gone The array row is why this is a structural span scan and not the `null` special-case the audit offered as an alternative: that fixes one shape and leaves the other. Both share a cause -- JSON.stringify of a PARSED value is not the caller's bytes, and a pretty-printed body defeats the search even for plain strings. scanContentSpans walks the raw body and returns each spliceable slot's [start, end) span in entry order; spliceBySpans overwrites those ranges. A span is where the value is, so it is correct for every content shape, and repeated blocks need no forward cursor -- the cursor requirement the audit said must survive survives by becoming unnecessary, not by being dropped. The old value search is kept as a fallback, so a payload the scanner declines behaves exactly as before and this change can only add savings. Declining stays the failure direction: the scanner refuses a non-object root, absent or non-array messages, a message with no content key, a truncated body, or a missing expected system; the splice refuses when spans do not ascend across the entries it replaces. Invariant 8 untouched: still only cleanup:session-dedup, still no cross-turn saving. What this recovers is the within-payload saving on payloads carrying a non-string content. The scanner tests are mostly about refusal, and every accepted span is checked by slicing the input and parsing the result -- a wrong span does not lose a saving, it corrupts a field sent to a provider. Adversarial cases: "content" inside a string value, a meta.content decoy preceding the real key, escaped quotes and backslashes, brackets inside strings, and a pretty-printed body. Verified: typecheck, lint and build clean, 88 files / 813 tests. The two byte-count cases were confirmed failing against the unfixed tree first. Three bench tests flaked once under parallel load and passed in isolation and on re-run -- they spawn python per fixture, which is OX-M15, still open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Audit OX-H4 — the highest-value finding in
oxaudit.md, and the first Lane B item taken.DECISIONS §65.
The defect
Egress splices replacements into the caller's raw bytes rather than re-serializing (invariant 9),
and located each message by searching the raw body for
JSON.stringify(text)— wheretextcomesfrom
flattenMessageContent, which sends every non-string content throughJSON.stringify.For
content: nullthat yields the four-character stringnull, so the search string became"null"with quotes, which does not occur where the body holds a barenull.spliceIntoRawBodyreturnsundefinedon the first miss, andforwardableBodymaps that backto the untouched body. So one unmatchable message discarded the replacements for every other
message in the payload.
content: nullis the standard OpenAI assistant tool-call shape, soessentially every agentic OpenAI conversation carries one.
Measured
A three-times-repeated block on a payload the Gateway does save on, with a turn 1 seeding the store:
content: nulltool-call turnThe array row is why this is a span scan and not the
nullspecial-case the audit offered as analternative — that fixes one shape and leaves the other. Both share a cause:
JSON.stringifyof aparsed value is not the caller's bytes. It isn't reliably so even for strings; a pretty-printed
body defeats the search too.
The fix
scanContentSpanswalks the raw body structurally and returns each spliceable slot's[start, end)span in the order entries are built (systemfirst for Anthropic, then messages).spliceBySpansoverwrites those ranges directly.A span is where the value is, so it's correct for every content shape, and repeated blocks — the
case
session-dedupexists for — need no forward cursor. The cursor requirement the audit said"must survive any rewrite" survives by becoming unnecessary, not by being dropped.
Two things deliberately kept:
forwardableBodytries spans first. A payload thescanner declines behaves exactly as before, so this can only add savings.
non-array
messages, a message with nocontentkey, a truncated body, or a missing expectedsystem.spliceBySpansadditionally refuses when spans don't ascend across the entries itreplaces — the case where a backwards splice would corrupt.
Invariant 8 is untouched. Still only
cleanup:session-dedup, still no cross-turn saving. Whatthis recovers is the within-payload saving on payloads carrying a non-string content.
Why the tests look like this
This code decides which bytes of a caller's request get overwritten. A wrong span doesn't lose a
saving — it corrupts a field being sent to a provider, the one direction invariant 3 forbids. So
the unit suite is mostly about refusal, and every span it accepts is checked by slicing the
input with it and parsing the result. Adversarial cases:
"content"inside a string value, ameta: { content: … }decoy preceding the real key, escaped quotes and backslashes, braces andbrackets inside strings, a pretty-printed body, and
systemwritten aftermessages.The integration suite adds the property that matters more than the saving: the forwarded body still
parses, message count and roles are unchanged, and the tool-call turn comes back exactly as sent,
nullincluded. That one would have passed before the fix too — declining is safe — and it's thereto stay true afterwards, which is the harder half.
What this does not establish
instrument here is the Gateway integration suite.
system-after-messagesis still a partial decline. Entries listsystemfirst, so such apayload yields non-ascending spans; the splice proceeds when
systemhas no replacement anddeclines when it does. Ordering entries by span position would close that; not attempted.
flattenMessageContentis unchanged. Structured content is still tagged'structured'andstill unelidable. This lets other messages be elided despite one, not that one be elided.
Verification
npm run typecheck,npm run lint,npm run buildandnpx vitest runall pass: 88 files /813 tests. The two byte-count cases were confirmed failing against the unfixed tree first.
One note: three
benchtests flaked on a single full-suite run and passed in isolation and onre-run. They spawn
pythonper fixture — that's OX-M15, still open — and my change touches onlysrc/gateway/proxy.ts, which bench does not use.🤖 Generated with Claude Code