Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

ClassRecorded asReplay compares
Plainverbatimexact
Volatileverbatimpresence and kind
Sensitivemasked placeholderpresence and kind
Omittednot recordednot compared
Requiredverbatimpresence, 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.