Skip to content

fix(gateway): anchor egress on span positions, not re-serialized values (OX-H4) - #39

Merged
ojassug merged 1 commit into
mainfrom
audit/lane-b-h4
Aug 29, 2026
Merged

fix(gateway): anchor egress on span positions, not re-serialized values (OX-H4)#39
ojassug merged 1 commit into
mainfrom
audit/lane-b-h4

Conversation

@ojassug

@ojassug ojassug commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

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) — where text comes
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, which does not occur where the body holds a bare null.

spliceIntoRawBody returns undefined on the first miss, and forwardableBody maps that back
to the untouched body. 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 carries one.

Measured

A three-times-repeated block on a payload the Gateway does save on, with a turn 1 seeding the store:

payload sent forwarded
all-string content (control) 8,685 saving lands
one content: null tool-call turn 8,685 8,685 — entire saving gone
one array (multimodal) content part 8,530 8,530 — entire saving gone

The array row is why this is a 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. It isn't reliably so even for strings; a pretty-printed
body defeats the search too.

The fix

scanContentSpans walks the raw body structurally and returns each spliceable slot's
[start, end) span in the order entries are built (system first for Anthropic, then messages).
spliceBySpans overwrites those ranges directly.

A span is where the value is, so it's correct for every content shape, and repeated blocks — the
case session-dedup exists 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:

  • The old value search, as a fallback. forwardableBody tries spans first. A payload the
    scanner declines behaves exactly as before, so this can only add savings.
  • Declining as 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. spliceBySpans additionally refuses when spans don't ascend across the entries it
    replaces — the case where a backwards splice would corrupt.

Invariant 8 is 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.

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, a
meta: { content: … } decoy preceding the real key, escaped quotes and backslashes, braces and
brackets inside strings, a pretty-printed body, and system written after messages.

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,
null included. That one would have passed before the fix too — declining is safe — and it's there
to stay true afterwards, which is the harder half.

What this does not establish

  • Nothing about the corpus. The harness measures CLI routes; the Gateway isn't in it. The
    instrument here is the Gateway integration suite.
  • Nothing about cross-turn saving. §41 stands.
  • system-after-messages is still a partial decline. Entries list system first, so such a
    payload yields non-ascending spans; the splice proceeds when system has no replacement and
    declines when it does. Ordering entries by span position would close that; not attempted.
  • flattenMessageContent is unchanged. Structured content is still tagged 'structured' and
    still unelidable. This lets other messages be elided despite one, not that one be elided.

Verification

npm run typecheck, npm run lint, npm run build and npx vitest run all pass: 88 files /
813 tests
. The two byte-count cases were confirmed failing against the unfixed tree first.

One note: three bench tests flaked on a single full-suite run and passed in isolation and on
re-run. They spawn python per fixture — that's OX-M15, still open — and my change touches only
src/gateway/proxy.ts, which bench does not use.

🤖 Generated with Claude Code

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>
@ojassug
ojassug merged commit 38cf5ce into main Aug 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant