Fixtures and Sensitive Data: The Classification Model
Recording a real exchange means writing a real request and a real response to a file you intend to commit. Everything in that exchange is a candidate secret: the credential in a header, the signature in a URL, the token in a response body, the client secret an error envelope echoed back. The framework’s answer is not a list of things to scrub. It is one question asked of every recorded item — what is this? — answered once, where the answer is known, and a gate that refuses to commit a file whose writer could not answer it.
One class per item
Every header, query parameter, and body field carries exactly one
FixtureClass, and the class alone decides how it is persisted and how it
is compared:
| Class | Recorded as | Replay compares |
|---|---|---|
Plain | verbatim | exact |
Volatile | verbatim | presence and kind |
Sensitive | masked placeholder | presence and kind |
Omitted | not recorded | not compared |
Required | verbatim | presence, asserted even without a fixture |
Plain is the default and needs no thought. Volatile is for values that
change every run — a request id, a cursor, a user-agent — which must be
there but will never be the same twice. Sensitive is for secrets: the value
is replaced with a placeholder before anything reaches disk, and comparison
never looks at it again. Omitted keeps an item out of the fixture entirely.
Required is the one match-side class: it records like Plain, compares like
Volatile, and additionally fails the match when the live item is absent — the
way to assert that the client generated a correlation id or a signature header
without pinning its value.
Two classes describe the data (Volatile, Sensitive) and mirror the marks a
wire model declares; two describe the fixture (Omitted, Required) and
exist only on the test side.
Four tiers, in order of preference
1. On the model
#[derive(WireModel, WireDecode)]
pub struct Oauth2Token {
#[wire(sensitive)]
pub access_token: String,
#[wire(sensitive)]
pub refresh_token: Option<String>,
pub token_type: String,
#[wire(volatile)]
pub issued_at: u64,
}
This is the tier to reach for. A class declared on the model rides with the
model: wherever that type is encoded into a request body, decoded from a
response, carried in a query proxy, or sent as a WebSocket message, the
declaration travels with it. It needs no test configuration, cannot go stale
relative to the field it describes, and is right for every test that ever
touches the endpoint. Redaction is the chapter on the
marks themselves — the placeholder forms, sensitive(mask | redacted | redacted_fn), and the Redact transform the derive emits.
For a response body the declaration reaches the recorder through the endpoint’s decoder. A response head carries no wire model, and by the time one arrives the endpoint that knew how to read it has been consumed — so the decode recipe rides out with the request, and the response is matched to it by status class:
impl DecodeBodyFn for PostToken {
fn decode_fn() -> BodyFnDecoder<Self> { ParcelResponse::decode }
fn response_specs(config: &ParcelConfig) -> Option<ResponseSpecs> {
Some(ResponseSpecs::wire::<Oauth2Token, ParcelErrorWire>(config.json_lib().erase()))
}
}
The success model and the error envelope are separate declarations because they
are separate models — and because error bodies routinely echo request parameters
back, secrets included; ok_only and error_only declare one side when the
other has no body worth describing. A response that does not decode under the
recipe its own decoder declared is a hard recording error: nothing is persisted,
because nothing can be shown safe to persist.
Streams are declared per event, because a stream is not one document. The shape
depends on the event name beside it, so the description rides the request head
under the type belonging to the crate that parses the framing, and
with_error_envelope pairs the two into the StreamSpecs a streaming decoder
returns:
impl DecodeSse for WatchShipment {
type Item = ShipmentDelta;
fn response_specs(config: &ParcelConfig) -> Option<StreamSpecs> {
let codec = config.json_lib().erase();
Some(
EventStreamSpec::sse::<ShipmentDelta>(codec)
.with_sentinels(&["[DONE]"])
.with_error_envelope::<ParcelErrorWire>(codec),
)
}
}
What no spec describes is framing: a body that is a run of length-prefixed
messages, as a gRPC call’s is, has no whole-body value and is kept exactly as it
arrived, to a .bin sidecar, compared byte for byte per message.
2. By name, on the test config
Some surfaces no model describes: a header a scheme adds, a cache-busting query parameter, a provider-specific key in an extension map. One builder family covers them:
ReplayConfig::default()
.classify_header("x-parcel-request-id", FixtureClass::Required)
.classify_header("x-parcel-nonce", FixtureClass::Omitted)
.classify_response_header("set-cookie", FixtureClass::Sensitive)
.classify_query_param("X-Amz-Signature", FixtureClass::Sensitive)
.classify_body_key("vendor_token", FixtureClass::Sensitive)
.classify_oauth2_body_keys() // the OAuth2 request and response keys, in one call
A name rule can tighten what a model leaves Plain; it cannot loosen a
#[wire(sensitive)] field back to exact comparison. Masking applies both
channels and comparison unions their anchors, so a declared secret stays a
secret whatever a test config says about its name. The one credential surface
that needs a name rule is a presigned URL: its signature and token parameters
are minted outside the pipeline and never traverse the seal, so nothing stamps
them, and the suites that exercise them class those names — X-Amz-Signature,
X-Amz-Security-Token — with classify_query_param.
3. By value
A secret that leaks across surfaces — a tenant id that appears in a URL path, a header, and a body — is registered once by value, with the placeholder the fixtures will hold:
ReplayConfig::default()
.with_sensitive_env_prefix("PARCEL_")
.with_sensitive_file("tests/sensitive.local.env") // gitignored; live values only
.with_sensitive("api_key", "pk-DUMMY_KEY")
.with_sensitive("tenant_id", "00000000-0000-4000-8000-000000000001")
The live value is resolved when recording — PARCEL_TENANT_ID in the
environment, then the dotenv-style file — and replaced by the placeholder
everywhere it appears, in both directions. In replay, api.sensitive("api_key")
hands back the placeholder, so one test source drives both modes.
The registry can also learn. Masking is the one moment that knows both that
a value is secret and what it is, and with learn_secrets(true) every live
string structural masking replaces joins the run’s substitution list and is
rewritten wherever else it appears — a secret echoed in an unmodelled response
field, spliced into a URL path, repeated in a later request. It is off by
default because learning is a content match, and it bets that no secret is also
a substring of something innocent; a one-character token or a numeric id loses
that bet by rewriting every fixture that happens to contain those bytes. Turn it
on for a suite whose secrets are long and opaque and whose services echo them
where no model declares them.
4. The twin
The three tiers above mask a request after it is encoded. The fourth records
it redacted before it is encoded: api.query_redacted(endpoint) dispatches
the live request as usual, but the fixture the recorder writes — and the request
replay compares — is a twin built from a redacted clone of the endpoint,
finalized under RequestOverrides::redact_credentials so the seal places
placeholders where every credential and signature would go. Nothing in the twin
ever held a live value, so nothing has to be found. It asks the endpoint to be
Clone and ApiRedact, which a WireModel endpoint is
(Redaction), and it is the only route for a
secret inside a gRPC frame, where nothing can be masked in place. A FileBlob
marked #[wire(sensitive)] becomes, under the twin, an empty blob with its MIME
type kept and its file name placeholdered — a filesystem path or a browser
handle belongs in no fixture, the part’s content type is structure rather than
payload, and the file name is often the most identifying thing about an upload.
What fills itself in
Two of those tiers keep working when nobody remembered them.
The pipeline records what it placed. An endpoint declares that it needs
auth; the header or query name a credential lands in is chosen later, by the
scheme or a placement directive. The seal records every header and query
parameter it placed from the store, and every header the request’s signer
declares as its outputs(), so the recorder classes them Sensitive with no
rule to remember — a custom scheme’s x-api-key, a signing scheme’s signature
header — and replay compares them by presence for the same reason. A
HeaderValue the pipeline flagged (is_sensitive()) masks whether or not
anything named it. An explicit config rule is the deliberate override, for the
test that means to record one verbatim.
The seal’s shape is the twin’s. redact_credentials keeps exactly the
placements a live send would write and substitutes the placeholder, so the twin
needs no list of credential names either.
Failure is loud, and it happens before dispatch
Request fixtures are written before the request is sent. A body that will
not decode under its own declared model, a placeholder that will not parse into
its field’s lane, a masking step that cannot be applied — any of these aborts
the recording before a request whose secrets cannot be safely persisted goes
out, and leaves no half-written fixture behind. The one exception is a
client-streaming gRPC body under StreamingCapture::Spooled: the send is the
read, so that request fixture is written when the body ends, with the sidecar
recording how it ended.
Response masking fails the same way, minus the abort it is too late for: nothing is persisted, the error names what failed, and the test fails. A silently missing fixture is recoverable; a silently unmasked one is not, which is why the framework refuses to guess.
The refusals extend to the asks it cannot meet. A whole-body model cannot be recorded as a timing capture if masking would rewrite it, because the recorded chunk boundaries would not be the ones the network produced — declare an event stream instead, and masking works at event boundaries where re-cutting is honest. A stream event whose payload is neither a declared protocol marker nor decodable under its model stops the recording. A body-key class over a gRPC body that crossed the wire is refused, and a registered value that appears in a gRPC body — a twin’s included — is refused too: a byte-level replacement would leave the frame prefix and the length-delimited field both describing the value it replaced, and a registered value inside a twin is one the twin did not redact. And a gRPC reply is recorded exactly as the service sent it — a service that mints a credential in a frame writes it into the fixture in the clear, and the declaration to make is on the reply type.
Every fixture says what its writer enforced
Committed fixtures carry an attestation in the .meta sidecar beside them:
capi-meta 1
request.date=2026-01-01T00:00:00Z
request.masking=masked
response.date=2026-01-01T00:00:00.150Z
response.masking=masked
masked means the file was written under fail-closed masking; a body with no
sensitive field still earns it, because the claim is that anything sensitive
would have been masked. unmasked means enforcement was off or degraded — an
unmasked diagnostic capture, or a best-effort pass that hit a warning — and the
recorder writes it, knowing which occurred. synthetic means a person wrote
the file, with sensitive fields deliberately holding valid-looking made-up
data. Each key speaks for one file: request.masking and response.masking
for the two sides, ws.masking for the .ws session belonging to them, and
artifact.masking for an OAuth2 callback capture, which is numbered on a
counter of its own and carries a sidecar of its own. That one most needs the
gate: it holds an authorization code, and it is a .txt.
The gate is positive, and the suite runs it itself as one more native test —
FixtureAudit::scan("tests/data")?.check()?, as the previous
chapter shows. masked or synthetic passes;
unmasked fails; an absent attestation fails too, because unknown
provenance is exactly the case worth reading rather than assuming safe; and a
file whose sidecar carries a capture= line is a working artifact — a
diagnostic capture, a dev trace, or a marker this framework predates — and is
refused outright, whatever its masking says.
cargo capi fixtures is the same audit from the command line, and two flags
manage the one claim a machine cannot make. --stamp synthetic writes that
disposition into every sidecar that attests none — a claim made on your behalf,
for a corpus you know never held a real secret — and --list-synthetic prints
the attested set, so an audit reads a short list of claims instead of grepping
a corpus for plausible-looking tokens. The marker is an attestation, not proof:
nothing offline can re-verify masking without the runtime specs that drove it,
which is why synthetic is the disposition held to review.
When nothing describes the payload yet
Automated masking will meet a response it cannot handle on first contact. That is what the declarations are for — and you cannot write them for a shape you have not seen. The sanctioned path is two passes, and the second is automated:
1. Capture once.
let client = ReplayClient::diagnostic(ParcelReplay, "tests/data", "onboard_shipments");
// …or `.unmasked()` when the point is to see exactly what crossed the wire
A diagnostic capture always records, and never drains: the application reads
the live stream while a tee records what it pulled and when, so stream timing —
often the thing under diagnosis — survives. Masking is best-effort and never
aborts the exchange being observed; an artifact that hit any warning is stamped
unmasked. The artifact lands in diagnostic/<test_name>/ under the root with
capture=diagnostic in its sidecar, so nothing mistakes it for a fixture.
2. Declare the classes the capture shows you need — #[wire(sensitive)] on
the models, classify_* for the surfaces no model describes, registry entries
for values that leak across several. This is the human step and the durable
one: the declarations outlive the capture and shape every fixture recorded
afterwards.
3. Remask.
CAPI_REMASK=1 cargo test onboard_shipments
The test runs unchanged. The real endpoints build the real requests, so the current declarations ride them — but every response comes from the capture rather than the network. Each exchange is masked under those declarations and promoted into the test’s fixture directory through the same fail-closed path a live recording uses. Step 3 is cheap to repeat, which is the point: a quota-priced or rate-limited API is called once, and every declare → check → declare more iteration runs against bytes already on disk. Delete the quarantined capture when the fixtures replay green; the gate refuses it until you do.
For the payload no declaration can describe — a secret that is positional, or
buried inside a string no field boundary reaches —
ReplayConfig::with_fixture_transform is the last resort: a closure over the
decoded body, applied after the declared classes. A .ws session has the same
escape hatch per lane, with_ws_outgoing_transform for the frames the client
sends and with_ws_incoming_transform for the ones the server does — the lane a
server-issued token arrives on — each reaching a frame only where the session
declared a spec for it. All three are test-side only, deliberately: a transform
that had to live in a client library would be a sign the model is wrong.
Two timings, both legitimate
A fixture drains each body before the application sees it, so its timestamps measure network arrival — the right timing for an artifact that will be replayed, and what makes fail-closed masking possible at all: the whole exchange is known before anything is written.
A diagnostic capture tees, so its timestamps measure application pull —
end to end, backpressure included. Draining would destroy exactly the
observation it exists to make. Neither is more correct; they answer different
questions, which is why the purpose is named rather than inferred. A teed body
flushes from Drop, which cannot return an error, so failures are collected and
surfaced by client.finish() — call it when you want to know that every capture
reached disk.
Where to put the declaration
When you are unsure which tier a thing belongs in, ask who knows:
- The model knows a field is a credential. Declare it there, and every test benefits.
- The test knows that a particular header is noise in this scenario. Classify it there.
- The run knows the live value of a shared identifier. Register it by value.
- The endpoint knows its own fields, and a request whose secrets ride the body — or a gRPC frame — is recorded as its twin.
Anything the pipeline chooses at runtime — a placed credential’s header name, a signer’s output — declares itself, because the code that chose it is the only code that could.
Next: Developing Against a Live API — the same machinery turned toward an application under development, where the fixtures are a trace rather than a test.