Skip to content

✨ feat(fixtures): pin the content-encoding decode policy to a shared corpus - #295

Merged
jasonwc merged 3 commits into
mainfrom
jason/capture-policy-fixture-corpus
Aug 7, 2026
Merged

✨ feat(fixtures): pin the content-encoding decode policy to a shared corpus#295
jasonwc merged 3 commits into
mainfrom
jason/capture-policy-fixture-corpus

Conversation

@jasonwc

@jasonwc jasonwc commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Capture policy has two implementations — the Go one here, and a Rust one in the client.
Their agreement has been a point-in-time claim by whoever last read both files, and that
decays silently. It decayed: the client dropped every compressed request body while this
path decoded it fine, and nothing failed until someone ran a session and noticed the rows
were missing.

This adds a shared corpus for content-encoding decode, so the two implementations are
gated against the same cases instead of against each other's source. It generalises the
pattern already used for the envelope fixtures.

Format

Cases specify a body either as a recipe — a plaintext, a list of encodings to apply,
an optional truncation — or as literal base64 where the bytes are the assertion.

Recipes are the default because compressed output is not stable across implementations:
Go's compress/gzip and Rust's flate2 do not emit identical bytes, and neither do the
two zstd libraries. Pinning compressed bytes would assert compressor identity, which is
not the policy and which no second implementation could satisfy. The cap cases decode to
32 MiB, which is not committable either way.

25 cases

identity (5) · supported codings (4) · stacked layers (2) · salvage (4) · limits (4) ·
errors (6)

Three questions the corpus forced into the open

zstd bounding. Two 14-byte frames differing only in the window descriptor show the
window bound is observable from outside the decoder, and both implementations place it at
log2(32 MiB). That agreement is now a gate. Maximum-memory is asserted nowhere — with the
window already at the output cap, no constructible frame distinguishes a decoder that sets
it, so a case would assert a library's API surface rather than the policy.

Empty bodies. The case pins the decoder. "Don't call the decoder for a bodiless
request" is recorded as a caller precondition that no decoder test asserts, which makes an
early return in a caller conformant rather than divergent.

Failure classes. Three — unsupported, oversize, undecodable — because both
implementations already distinguish exactly three. Corrupt input and a refused window stay
merged, with the difference in an explicitly unasserted field. Splitting them is a change
to both implementations first and the corpus second.

It found something immediately

DecodeContentEncoding([]byte{}, "gzip") errors while ([]byte{}, "zstd") returns success
with zero bytes — identical input, opposite outcomes, decided by which decoder the header
named. That is documented here as a case rather than quietly fixed, and repaired in the
follow-up.

Gates

An oracle over every case, a digest seal so a stale vendored copy fails, and coverage rules
so deleting the last case for a behaviour fails loudly. All three are mutation-tested.

Two failures on a full go test ./... reproduce on a clean tree and are unrelated: the
OpenAPI contract seal, and the Postgres suite, which needs the database make test
provisions.

related to PCC-1126

@linear-code

linear-code Bot commented Aug 6, 2026

Copy link
Copy Markdown

PCC-1126

@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR introduces a shared, digest-sealed content-encoding corpus that aligns independently implemented Go and Rust capture policies.

  • Adds fixtures covering identity handling, supported and stacked encodings, concatenated gzip members and zstd frames, salvage behavior, resource limits, and errors.
  • Adds a Go oracle that builds recipe-based bodies and validates decoded outcomes.
  • Adds named, bidirectional coverage checks so every policy rule has a dedicated fixture and every fixture has a declared rule.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
pkg/capture/contentencoding_corpus_test.go Adds the corpus loader, recipe builder, reference oracle, digest verification, and named bidirectional policy-coverage gate; the previously broad coverage checks are now tied to dedicated cases.
fixtures/content-encoding/cases/gzip-concatenated-members.json Pins complete decoding of concatenated gzip members using independently compressed plaintext chunks.
fixtures/content-encoding/cases/zstd-concatenated-frames.json Independently pins complete decoding of concatenated zstd frames.
fixtures/content-encoding/cases/salvage-refused-when-nothing-produced.json Uses a format-derived absolute prefix instead of a compressor-relative truncation ratio, addressing the prior portability concern.
fixtures/content-encoding/README.md Documents the language-neutral fixture schema, member construction, decode outcomes, failure taxonomy, sealing, and coverage contracts.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  C[Shared JSON corpus] --> B[Build body recipe]
  B --> G[Go DecodeContentEncoding oracle]
  B --> R[Rust client decoder]
  G --> P[Assert shared decode policy]
  R --> P
  C --> D[DIGEST freshness seal]
  C --> V[Named policy coverage gate]
Loading

Reviews (3): Last reviewed commit: "✨ feat(fixtures): pin that a concatenate..." | Re-trigger Greptile

Comment thread pkg/capture/contentencoding_corpus_test.go Outdated
Comment thread fixtures/content-encoding/cases/salvage-refused-when-nothing-produced.json Outdated
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 7, 2026

Copy link
Copy Markdown

Deploying docs-tapes-dev with  Cloudflare Pages  Cloudflare Pages

Latest commit: 35c79be
Status: ✅  Deploy successful!
Preview URL: https://9255f71a.tapes-dev.pages.dev
Branch Preview URL: https://jason-capture-policy-fixture.tapes-dev.pages.dev

View logs

@jasonwc

jasonwc commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

Both findings addressed.

Salvage case portability. The truncation is now an absolute keep_head_bytes: 9 instead of a ratio. 9 comes from the frame structure — 4-byte magic + the smallest legal frame header (2) + a block header (3) — so byte 10 is the earliest position at which any encoder's first byte of block content can appear, and a block that has delivered no content cannot have produced output whatever its type. A larger frame header only pushes that further out, so the bound holds for every encoder rather than for the one that generated the fixture. The schema had no absolute-head form, so keep_head_bytes was added alongside the existing two, and the README now says what each form holds fixed across compressors. The asserted outcome is unchanged: probed at keeps of 4/5/6/7/8/9/10/12/44 bytes, the decoder returns unexpected-EOF with zero output at every one.

Coverage predicates. The gate is now a declared table of {rule, the case name that pins it, predicate}, with the predicate run against that named case rather than searched across the corpus. All three failure modes verified: deleting a pinning case, editing it so it no longer pins its rule, and adding a case no rule names — the last closes the table in the other direction so it cannot fall behind the corpus.

Worth recording on your DIGEST question: the seal alone would not have caught a deletion. It is recomputed from whatever cases exist, so delete-and-reseal is a legal two-line green diff. It is the named-case gate, not the seal, that stops that.

@jasonwc

jasonwc commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

One more case pair on this branch, from a defect found reviewing the client that consumes this corpus.

Its gzip binding stopped after the first member of a concatenated stream and reported success, while compress/gzip here spans members by default. Same input, different captured body, no error on either side — exactly the drift this corpus exists to make loud, and invisible until the two were run against the same case. gzip-concatenated-members pins it.

zstd-concatenated-frames pins the case that already agrees. The two codings go through unrelated libraries on each side, so agreement on gzip says nothing about zstd, and a rule that is only pinned for one coding is one a future binding swap can silently drop for the other.

The body spec gained a members form for this. It splits the plaintext before compressing, not the encoded stream — a split of encoded bytes would cut at an offset only one encoder's output has, and the case could no longer assert against the original plaintext.

jasonwc added 3 commits August 6, 2026 18:37
…corpus

The envelope and thread corpora make *header* contracts executable. This does
the same one layer in, for a *policy*: which content-codings a captured body
may use, how stacked layers compose, how much output is allowed, and what a
corrupt or half-arrived stream is worth.

That policy is implemented independently in Go (pkg/capture/contentencoding.go,
the reference) and in Rust (tapesctl's client decoder). Their agreement was a
point-in-time claim by whoever last read both files, and it had already decayed
once: the client dropped every zstd request body — all of Codex/pi's traffic —
while the gateway route decoded the same bytes fine, with nothing red.

Bodies are recipes (a plaintext, the layers to apply, an optional truncation)
rather than committed bytes. Compressed output is not stable across
implementations, so pinning it would assert compressor identity instead of the
policy, and would make review a diff of base64 blobs. Byte-exact base64 is kept
only where the bytes are the assertion: two hand-built zstd frames differing
solely in their window descriptor, two deliberately corrupt frames, and the
empty body.

Three cases record a decision rather than a settled rule, in a `contested`
block that travels with the case into every vendored copy:

- the zstd bound is asserted as the window only. Go also passes a max-memory
  bound and the Rust binding cannot; with the window already at the output cap,
  no constructible frame distinguishes them, so a case for max-memory would be
  asserting a language binding's API rather than the policy.
- a bodiless request whose headers still claim an encoding errors at the
  decoder, and must never reach it. Not-calling-the-decoder is recorded as a
  caller precondition, which a proxy that returns early satisfies.
- the same empty body under zstd currently succeeds with zero bytes. That is
  recorded as observed and flagged as a suspected bug rather than promoted to
  a rule — encoding the intended answer would make the case fail on the very
  implementation it describes.

The failure taxonomy is three classes because both implementations already
distinguish exactly three; corrupt-body and window-refused stay merged under
`undecodable`, with the difference in unasserted prose, rather than inventing a
distinction neither side carries. This is not extproc's DropResponseDecode
metrics label, which still collapses all three and is untouched.

related to PCC-1126
… produced output

The case that pins the second half of the salvage rule — a stream that ended
early AND produced nothing is refused, not salvaged — truncated its zstd body at
half its encoded length. Whether a complete block lands inside that half is a
property of the compressor, not of the decoder: its block splitting and its
level decide it. A conformant decoder handed another compressor's stream could
legitimately produce output there, correctly report a salvage, and fail a corpus
whose whole premise is that it asserts policy rather than compressor identity.

The cut is now an absolute prefix derived from the container format instead: 9
bytes, being the 4-byte magic plus the smallest frame header the format allows
plus a block header. Byte 10 is the earliest position at which any encoder's
first byte of block content can appear, and a block that has delivered no
content can have produced no output whatever its type — so "nothing was
producible" is true for every encoder rather than for the one that built the
fixture. A larger frame header only pushes that byte further out. The error is
unchanged, including its unasserted detail, because an unfinished frame reports
the same early end wherever it was cut.

That needs a truncation form the schema did not have. drop_tail_bytes fixes what
is missing and keep_head_ratio fixes a fraction; neither can express a cut point
that comes from the format rather than from one encoder's output length, so
keep_head_bytes is added beside them and the three are documented by what each
holds fixed across compressors.

The coverage gate is rewritten in the same pass, for a failure of the same
shape. It asserted properties over the whole corpus — "some case decodes gzip" —
which any neighbour sharing the shape satisfies: the alias case, a cap case, a
stacked case. Deleting the dedicated case therefore left the gate green while
the rule it pinned was pinned nowhere, and the seal does not catch that either,
since a digest recomputed from the surviving cases makes delete-and-reseal a
legal two-line diff. Each rule now names the case that pins it, and the
predicate runs against that case, so a deletion fails and so does gutting the
case in place. The table is closed in the other direction too: a case no rule
names fails, which keeps it from falling behind the corpus it describes.

related to PCC-1126
…st member

A gzip stream is a series of members (RFC 1952 2.2) and zstd frames may
likewise be concatenated. A streaming compressor that flushes and restarts
mid-body emits exactly that shape, so it is ordinary request traffic rather
than an edge case.

It is worth a case because of how it fails. A decoder that stops at the first
member returns *success*, reports no truncation, and hands back a prefix, so
the capture is silently short and the loss reappears far downstream as a parse
error with nothing pointing at the decoder. Every other way these decoders can
lose bytes is loud; this one is not.

The two implementations of this policy did not agree on it. Go's compress/gzip
reader continues into later members unless Multistream(false) is set; the Rust
client's flate2::read::GzDecoder stops at the first, and only MultiGzDecoder
continues. Both sides already read every zstd frame, which is pinned here too
rather than left implicit: the two codings go through unrelated libraries, so
agreement on one is not evidence about the other, and an unpinned rule is one a
binding swap can quietly drop.

Expressing this needed a `members` count in the build recipe, alongside
`truncate`. It splits the *plaintext* rather than the encoded stream, so the
member boundary sits at the same logical offset whichever compressor built the
fixture and the case can still assert equality with the whole plaintext -- an
assertion a split of the encoded bytes could not make, since it would be
cutting at an offset only one encoder's output has.

related to PCC-1126
@jasonwc
jasonwc force-pushed the jason/capture-policy-fixture-corpus branch from 2c4607c to 35c79be Compare August 7, 2026 01:39
@jasonwc
jasonwc merged commit 3a1d897 into main Aug 7, 2026
26 checks passed
@jasonwc
jasonwc deleted the jason/capture-policy-fixture-corpus branch August 7, 2026 18:00
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