Files
6krrt/plans/text-integrity-audit.md
adlee-was-taken 3523dcf93e docs(plans): give every plan a Status line so the queue is greppable
plans/ held 58 documents and exactly one said whether it was open. The rest
mixed finished work, reviews of shipped work, parked specs and genuinely
pending ones, with nothing distinguishing them, so "how many plans are in
the queue" had no answer short of reading all 58.

Now `grep -H '^Status:' plans/*.md` is the answer:

    50 done   3 in progress   2 planned   2 reference   1 parked

Statuses were derived rather than guessed: CLAUDE.md's own built list and
"What's NOT built yet" section, plus checking the subject exists in the
code. A review of work that shipped counts as done -- it records what was
found, it is not a request for anything. `reference` separates the two docs
that are conventions rather than work items (admin-design-standards,
admin-work-framework), which otherwise read as permanently-open plans.

The vocabulary is deliberately five words. A larger one invites "mostly
done" and "blocked-ish", which is how the directory became unreadable.

test_plans_declare_status.py keeps it from rotting: a new plan without a
marker fails, as does an unknown status, one buried below the eighth line,
or an open status with no reason -- "planned" alone is the state that rots,
since nobody can tell later whether it waits on a decision, a dependency,
or just nobody's turn.

Also updates the sweep plan with what landed and what did not, including
that #9 was not a defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VRQXz5SYZYVWscxS1QqF6U
2026-09-08 18:55:16 -04:00

6.3 KiB

Text integrity: every place the router touches bytes

Status: in progress -- mutation points fixed; contract checks and canary probes still open

An audit of the mutation points, prompted by 8518114 -- a double-encode in the SSE proxy that corrupted every streamed non-ASCII character for months behind a green test suite.

The invariant this establishes, in one line:

Never re-serialize what you are only forwarding, and never cut text anywhere but a character boundary.

The sites

Every encode/decode/slice on content, classified by what it can do.

Forwarded to the client -- must be byte-exact

site what it does verdict
dispatcher.py SSE proxy forwards upstream bytes, decodes a private copy to sniff telemetry fixed in 8518114; was decode-latin1 + encode-utf8
dispatcher.py router-generated SSE (6 sites) json.dumps(...).encode() safe -- ensure_ascii=True makes the payload pure ASCII before encoding, so the encode is lossless

The second row is worth stating rather than assuming: those six lines look like the bug that was just fixed, and they are not, for a reason that is one keyword deep. If anyone ever passes ensure_ascii=False there to save bytes, they reintroduce a charset decision on an output path.

Sent to a provider or a local model -- corrupts the model's input

site what it does verdict
context_prune head/tail elision (3 sites) shortens old tool results fixed; now cuts on cluster boundaries
clamp_for_classifier head+tail clamp to max_input_chars fixed
verification.excerpt head+tail elision for the local checker fixed
request bodies requests(json=...) safe -- requests serializes with json.dumps, ASCII on the wire

These slice str, so none could ever produce mojibake -- Python indexes codepoints. What they could do is cut a grapheme cluster: the string "cafe" + U+0301 renders as four characters but stores five codepoints, and slicing it at 4 drops the accent while leaving a bare combining mark at the head of the tail. src/textcut.py moves the cut to the nearest boundary.

Severity is genuinely lower than the SSE bug -- a stray combining mark, not a mangled document -- but the failure shape is the one this project keeps paying for: the router corrupts the model's input, the model faithfully reproduces the corruption, and the output reads as the model's fault.

Diagnostic only -- never reaches the client

site what it does verdict
dispatcher.py:4442 decodes upstream bytes with errors="replace" to inspect safe by design -- the forwarded bytes are untouched; only the telemetry sniff and the logged completion see a replacement char
admin.py:1822 subprocess output, errors="replace" acceptable; a maintenance job emitting non-UTF-8 shows replacement chars in the portal
dispatcher.py:2691 sha256(content[:4000].encode()) session fingerprint safe; deterministic, never rendered
context_prune savings estimate len(text) - head_len - tail_len left alone deliberately -- it is a planning estimate, not a cut, and a 1-2 char difference changes nothing

Why the suite did not catch the SSE bug

This is the more useful half of the audit, because the code review that would have caught the decode never happened -- the tests were supposed to.

There is a test named test_a_stream_is_proxied_verbatim_including_the_telemetry_comments. It passed, for months, against a proxy that was not proxying verbatim. Two independent reasons, both now fixed:

  1. The assertions were ASCII. It checked three ASCII substrings. A test that only ever sees ASCII cannot observe a charset bug, by construction.
  2. The fakes modelled the boundary instead of exercising it. All five FakeResponse.iter_lines implementations yielded self._lines unchanged in both modes. A test holding str lines got those str back even under decode_unicode=True -- i.e. the fake modelled a stream that had already been decoded correctly. A fake that hands back the right answer cannot reproduce a decode bug.

The fakes now treat the wire as UTF-8 bytes whatever the test wrote, derive encoding from Content-Type via requests' own get_encoding_from_headers, and default to the charset-less text/event-stream that OpenRouter really sends. The hostile case is the default. The shared STREAM_LINES sample carries a raw UTF-8 em dash, so the whole streaming surface exercises it.

Measured by reverting 8518114 and running the suite:

tests that caught it
before this work 1 (the one written for the bug)
after 2, including the verbatim test that had been lying

And for the truncation sites, reverting textcut fails 3 of the 5 new call-site tests.

What this does and does not buy

Prevention covers faults the router introduces. It is the right first move and it is now largely done: the mutation points are enumerated, the two real classes are fixed, and the tests exercise the boundary rather than a model of it.

It covers none of:

  • a provider changing quantization, or silently serving a different model;
  • corruption upstream of us, in a provider's own edge;
  • a model that starts looping, refusing, or truncating.

It also has no memory. Once the audit is done there is no ongoing evidence the invariant still holds -- against a provider added next month, or one that changes its headers next quarter. OpenRouter was re-added under #45 and the corruption arrived with it.

So the natural follow-ons, cheapest first:

  1. Provider contract checks at poll time. Assert what we assume and never verify: SSE declares a charset, the stream shape parses, a model id resolves to a chat endpoint. No traffic, no spend. This bug was 100% inside it.
  2. Canary probes. A fixed string with an em dash, combining marks, CJK and a ZWJ sequence, echoed by each model and diffed exactly. The only thing that tests the composed system on the real wire rather than against a fake. seed_energy.py already establishes the pattern and llm-router-seed.timer already provides the schedule.

plans/mangled-output-detection.md covers what to do once something is actually detected, and argues for keeping the first response loud rather than corrective -- auto-mitigation would have hidden this bug rather than surfacing it.