Audit Wave 1: silent data loss, Gateway byte corruption, and a trace that explains - #3
Merged
Conversation
…t survived Closes the measurement half of audit C1. `findUnwitnessedItems` built its probe bundle from the *before* item, so it asked "did evidence exist?" rather than "did any of it survive?", and an item whose witnesses were all destroyed was exempt on the grounds that they had once been there. Measured on this repository's own files, on both the file and stdin routes: `CODE_OF_CONDUCT.md` went 3,542 -> 72 bytes and `SECURITY.md` 1,154 -> 72, at `fallbackUsed: false`, `validation.passed: true`, both gates reporting `pass`. On the CLI that is unrecoverable — no `TokenHasher` is supplied, so the removed bytes exist nowhere. Neither gate could fire, and the arithmetic is closed-form rather than a tuning miss. Prose yields no symbols, so `R_AST = 1.0` as an empty-set default and contributes a free 0.60. `filepath:` is derived from `item.path` and no content transform can destroy it, so `R_struct = 1/(N+1)` for N headings. Therefore `S_k = 0.4·N/(N+1)`, which approaches 0.40 from below and never reaches it, for any N, against a retention gate firing on `> 0.40`. The two stdin rows landed on exactly 0.400 and were admitted by the strict comparison — the supremum of the expression waved through by the operator, not a near miss. An item that changed is now refused when it yields no symbols and no content-derived markers survive in the *after* item. Two properties keep it safe: it is scoped to symbol-free items, so whole-item elision of code still refuses as SEMANTIC_DRIFT_EXCEEDED — the accurate reason, since `R_AST` measured that loss exactly — rather than being relabelled unmeasurable; and it only ever adds refusals, since refusing on the surviving set is strictly stronger than refusing on the before set, so every §33 refusal still refuses. Measured cost over a frozen 293-file corpus, 586 rows across both routes: 4 rows changed, and all four are this defect. Everything else is byte-identical to baseline — TypeScript 14.00%, Python 14.98%, every uncovered-language bucket 0.00%, all unchanged. The prose bucket goes 0.67% -> 0.00%, which was the loss. The new tests are verified to fail against the unfixed tracker (2 red), and the three that pin behaviour C1a must *not* change pass in both arms. Deferred: `filepath:` is still counted in `R_struct` (audit C1b). That is the deeper half — it is why `R_struct` is pinned at 1.0 for code and contributes a free 0.40, and therefore why a code file can lose 66.7% of its symbols and pass. It moves every published figure in the project and wants its own measurement pass. Also updates `tools/corpus-harness/recipe.json`: typescript 56 -> 57, prose 25 -> 28. Those buckets count this repository's own files, which grow; the harness refusing to run on the mismatch is it working as designed. See DECISIONS §37. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes audit C2 and L3.
`GatewayServer.onRequest` accumulated its body with `body += chunk`, which calls
`Buffer.prototype.toString('utf8')` on each chunk *independently*. A multi-byte
UTF-8 sequence straddling a chunk boundary is therefore decoded as two truncated
fragments and becomes U+FFFD on both sides — at the socket, before the pipeline
exists, on the bytes that are then forwarded to the provider. Node reads in
~64 KB chunks, so this fires by chance on any body large enough to be chunked and
deterministically for a body split at the wrong offset.
Measured against the unfixed server, each body written in two `req.write()` calls
split on a UTF-8 continuation byte:
héllo — ünïcode ✓ 日本語 😀 94B sent, 98B forwarded as `h??llo …`
こんにちは世界 76B sent, 82B forwarded
┌─┐│ build ok │└─┘ 89B sent, 95B forwarded
A corrupted body is always longer than it was sent, because U+FFFD re-encodes to
three bytes. Nothing was elided on any of these turns.
This is DECISIONS §35 at a different seam. Phase B's reasoning — "rawInput is a
decoded string, so the evidence is gone by the time a request exists" — is
correct and generalizes; it was applied to the adapter that reads from disk and
not to the one that reads from a socket, where it is worse, because the bytes
reach a provider rather than a terminal. MCP was never affected, instructively:
`setEncoding('utf8')` installs a `StringDecoder`, which holds partial sequences
across chunk boundaries. Manual concatenation is exactly what bypasses that.
The fix collects `Buffer[]`, concatenates on `end`, and decodes once. Then it
applies the CLI's own round-trip test and, when that fails, forwards the caller's
bytes untouched — concatenating correctly does not make a body that was never
valid UTF-8 representable, so that is a separate question with a separate answer.
Optimizing such a body is not an option, because every stage, validator and token
estimate operates on the decoded string and would be reasoning about content the
caller never sent. Rejecting it is not an option either: a body the provider
might well accept is not a transparent proxy's to refuse. `ProxyRequestResult`
gains an optional `bodyBytes`, preferred over `body` by both the upstream `fetch`
and the locally-returned branch of `writeProxyResult`.
Also removes the O(n²) body-size check, which recomputed
`Buffer.byteLength(body, 'utf8')` over the whole accumulated string on every
chunk (L3). A running total falls out of collecting buffers anyway.
The new tests choose their split offsets programmatically to land on a
continuation byte, so they test the actual hazard rather than an offset that
might be character-aligned, and are verified to fail 4/4 against the unfixed
server.
Untouched and independent: the `exec` token handoff (C3), the 0-bytes-saved
measurement (H1), structured content flattened to a string (C4), and the two
environment branches in the request path (M8). This is a correctness fix to the
pass-through, not a claim that the mode is finished.
See DECISIONS §38.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…it a budget they exceed
Closes audit M6.
`buildTrace` projected every `StageResult` down to
`{ stageId, status, durationMs: 0, changed }`. The stage's `metrics` and `notes`
were discarded and the duration was a literal constant. So the trace could say
that `compression:token-hashing` ran and changed something, and nothing about
what it removed, how much, whether any elision was reversible, or how long it
took — for a product whose stated differentiator is auditability. `--diff` and
`--diff-html` partially compensate on the CLI; the MCP `get_optimization_trace`
tool and the Gateway had nothing else.
`StageTrace` now carries `metrics` and an optional `notes` verbatim. A CLI trace
shows `regionsHashed: 4`, `bytesSaved: 14509`, `irreversibleElisions: 1` and the
note explaining that no token hasher was supplied so the removed content is
retained nowhere.
`durationMs` is measured by the engine rather than by the stage: a stage that
read a clock would stop being a pure function of its input (invariant 1), whereas
timing an opaque call from the outside is an observation about the stage and
cannot change what it returns. `performance.now()` rather than `Date.now()`,
because most stages finish inside a millisecond and integer resolution would
report the same uninformative 0 the constant already did. The trace was already
non-deterministic — it carries a UUID `requestId` — so this changes nothing about
invariant 1, which is a statement about emitted bytes.
The pruner's note was not vague, it was false. `pruning:topology-pruner` returned
"All items fit within token budget; no pruning required." unconditionally
whenever `itemsPruned === 0`. Measured, a 5,405-token file at
`maxInputTokens: 10` reported that all items fit.
The mechanism is also H5: `applyCacheAwarePrefixLocking` pins every item inside
the first 1,024 tokens, `solve01Knapsack` places pinned items outside the
candidate set and always selects them, and `createContextBundle` produces a
one-item bundle for CLI, MCP and bench — so item 0 is always pinned and
`itemsPruned` is always 0. The note announced that pruning was *unnecessary* for
the case where it was *impossible*. It now distinguishes the three cases and
names the mechanism, and the metrics carry `bundleTokens` and `maxTokens` so the
claim is checkable rather than asserted.
This does not fix H5 — the knapsack remains unreachable on every shipping path.
It stops the trace concealing it behind a reassuring sentence, which is the
necessary first step: the defect is now visible where a user would look.
New tests verified to fail 4/4 against the unfixed trace.
See DECISIONS §39.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 9, 2026
Merged
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.
Wave 1 of the
max_audit.mdremediation, following Wave 0 in #1. Three independent fixes, each verified against the unfixed code before landing.C1a — a document whose witnesses were all destroyed is no longer certified
ab24b96· audit C1 (measurement half) · DECISIONS §37DriftTracker.findUnwitnessedItemsbuilt its probe bundle from the before item, so it asked "did evidence exist?" rather than "did any of it survive?". An item whose witnesses were entirely destroyed was exempt on the grounds that they had once been there.Measured on this repository's own files, on both the file and stdin routes:
fallbackUsedS_kCODE_OF_CONDUCT.mdfalseSECURITY.mdfalsevalidation.passed: true, both gates reportingpass, and unrecoverable on the CLI — noTokenHasheris supplied, so the removed bytes exist nowhere.Neither gate could fire, and the arithmetic is closed-form. Prose yields no symbols, so
R_AST = 1.0as an empty-set default contributes a free 0.60.filepath:is derived fromitem.pathand no content transform can destroy it, soR_struct = 1/(N+1)for N headings. ThereforeS_k = 0.4·N/(N+1)— which approaches 0.40 from below and never reaches it, for any N, against a gate firing on> 0.40. The two stdin rows landed on exactly 0.400 and were admitted by the strict comparison: the supremum of the expression being waved through by the operator, not a near miss.An item that changed is now refused when it yields no symbols and no content-derived markers survive in the after item. Two properties keep this safe:
SEMANTIC_DRIFT_EXCEEDED— the accurate reason, sinceR_ASTmeasured that loss exactly — rather than being relabelled unmeasurable.Measured cost over a frozen 293-file corpus (586 rows, both routes): 4 rows changed, all four this defect. Everything else byte-identical — TypeScript 14.00%, Python 14.98%, uncovered buckets 0.00%, all unchanged. Prose goes 0.67% → 0.00%, which was the loss.
C2 — the Gateway reads bytes, not string fragments
99672af· audit C2, L3 · DECISIONS §38GatewayServer.onRequestaccumulated its body withbody += chunk, callingtoString('utf8')on each chunk independently. A multi-byte UTF-8 sequence straddling a chunk boundary became U+FFFD on both sides — at the socket, before the pipeline exists, on the bytes forwarded upstream.Measured against the unfixed server, each body written as two
req.write()calls split on a continuation byte:héllo — ünïcode ✓ 日本語 😀h��llo …こんにちは世界┌─┐│ build ok │└─┘A corrupted body is always longer, because U+FFFD re-encodes to three bytes.
This is DECISIONS §35 at a different seam — Phase B applied that reasoning to the adapter reading from disk and not the one reading from a socket, where it's worse because the bytes reach a provider rather than a terminal. MCP was never affected, instructively:
setEncoding('utf8')installs aStringDecoderwhich holds partial sequences across chunks; manual concatenation is exactly what bypasses it.Now collects
Buffer[], concatenates onend, decodes once. Bodies failing a UTF-8 round trip — which correct concatenation does not fix — are forwarded verbatim rather than optimized (every stage would reason about content the caller never sent) or rejected (not a transparent proxy's call). Also removes the O(n²) body-size check (L3).M6 — the trace carries what the stages computed
b59722a· audit M6 · DECISIONS §39buildTraceprojected everyStageResultdown to{ stageId, status, durationMs: 0, changed }, discardingmetricsandnotesand hardcoding the duration. A CLI trace now showsregionsHashed: 4,bytesSaved: 14509,irreversibleElisions: 1and the note explaining that no token hasher was supplied so the removed content is retained nowhere.durationMsis measured by the engine, not the stage — a stage that read a clock would stop being a pure function of its input (invariant 1), whereas timing an opaque call from outside cannot change what it returns.performance.now()because most stages finish inside a millisecond.The pruner's note was not vague, it was false.
pruning:topology-prunerreturned "All items fit within token budget; no pruning required." unconditionally wheneveritemsPruned === 0. Measured, a 5,405-token file atmaxInputTokens: 10reported that all items fit.The mechanism is also H5: prefix locking pins everything in the first 1,024 tokens, the knapsack always selects pinned items, and
createContextBundlebuilds a one-item bundle — so item 0 is always pinned anditemsPrunedis always 0. The note announced pruning was unnecessary where it was impossible. It now names the mechanism, withbundleTokensandmaxTokensin the metrics so the claim is checkable. This does not fix H5, but it stops the trace concealing it.Verification
npm run typecheck,npm run lint— cleannpx vitest run— 493 passing, 56 files (up from 481/54)tools/corpus-harness— frozen, hash-verified, both routesAlso updates
tools/corpus-harness/recipe.json(typescript 56→57, prose 25→28). Those buckets count this repo's own files, which grow; the harness refusing to run on the mismatch is it working as designed.Deliberately deferred
C1b —
filepath:is still counted inR_struct. That is whyR_structis pinned at 1.0 for code and contributes a free 0.40, and therefore why a code file can still lose 66.7% of its symbols and pass. It re-baselines every published reduction figure in the project and wants its own measurement pass.Still open from the audit: C3 (
exec401s its own child), C4 (structured content flattened to a string), H1 (Gateway saves 0 bytes), H2, H4, H5, H6, and the remaining M-tier findings.🤖 Generated with Claude Code