Skip to content

Audit Wave 1: silent data loss, Gateway byte corruption, and a trace that explains - #3

Merged
ojassug merged 3 commits into
mainfrom
audit-wave-1
Aug 9, 2026
Merged

Audit Wave 1: silent data loss, Gateway byte corruption, and a trace that explains#3
ojassug merged 3 commits into
mainfrom
audit-wave-1

Conversation

@ojassug

@ojassug ojassug commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

Supersedes #2, which was auto-closed when its head branch was renamed phase-c-code-is-not-typescriptaudit-wave-1. Same three commits, same diff; no review had happened on #2.

Wave 1 of the max_audit.md remediation, 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 §37

DriftTracker.findUnwitnessedItems built 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:

file before after fallbackUsed S_k
CODE_OF_CONDUCT.md 3,542 B 72 B false 0.369 / 0.400
SECURITY.md 1,154 B 72 B false 0.333 / 0.400

validation.passed: true, both gates reporting pass, and unrecoverable on the CLI — no TokenHasher is 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.0 as an empty-set default 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 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:

  • Scoped to symbol-free items. 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.
  • Strictly additive. Refusing on the surviving set is stronger than refusing on the before set, so every §33 refusal still refuses. Nothing caught is now let through.

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 §38

GatewayServer.onRequest accumulated its body with body += chunk, calling toString('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:

body sent forwarded
héllo — ünïcode ✓ 日本語 😀 94 B 98 Bh��llo …
こんにちは世界 76 B 82 B
┌─┐│ build ok │└─┘ 89 B 95 B

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 a StringDecoder which holds partial sequences across chunks; manual concatenation is exactly what bypasses it.

Now collects Buffer[], concatenates on end, 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 §39

buildTrace projected every StageResult down to { stageId, status, durationMs: 0, changed }, discarding metrics and notes and hardcoding the duration. A CLI trace now 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, 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-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: prefix locking pins everything in the first 1,024 tokens, the knapsack always selects pinned items, and createContextBundle builds a one-item bundle — so item 0 is always pinned and itemsPruned is always 0. The note announced pruning was unnecessary where it was impossible. It now names the mechanism, with bundleTokens and maxTokens in 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 — clean
  • npx vitest run493 passing, 56 files (up from 481/54)
  • Every new test run against the unfixed code first: 2/2, 4/4, 4/4 red
  • C1a's suite also contains three tests pinning behavior the fix must not change; those pass in both arms
  • C1a's corpus delta produced via tools/corpus-harness — frozen, hash-verified, both routes

Also 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

C1bfilepath: is still counted in R_struct. That is why R_struct is 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 (exec 401s 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

ojassug and others added 3 commits August 9, 2026 21:08
…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>
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