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

Value Classes and Redaction

Two attributes say what kind of value a field holds, and one of them — sensitive — is the start of a mechanism that runs through the whole family: the wire model’s typed redaction transform, Capi’s .redacted() on an endpoint, the seal’s placeholders for credentials and signatures, and the recorder’s twin. This chapter is the one place that mechanism is stated; the auth, signing, and testing chapters link here for it.

The classes

pub struct Session {
    #[wire(sensitive)] pub token: String,      // secret
    #[wire(volatile)]  pub request_id: String, // varies run to run
    pub account: String,                       // plain
}
ClassMeaningRecordedCompared
Plain (default)an ordinary valuein fullin full
volatilenon-deterministic — timestamps, nonces, request idsverbatimby presence and lane only
sensitivesecretmaskedby presence and lane only

The class is a property recorded in the field’s Binding — codecs and tooling read it there — and it is a statement about the field, not an instruction to any one consumer: say what the field is, and every consumer does the right thing with it. The header encoder honours sensitive by marking the emitted header value sensitive; the recorder masks it before a fixture is written and compares it by presence on replay; a comparison report substitutes the placeholder for sensitive descendants.

#[wire(skip)] fields belong to neither plane: they move verbatim, unmasked, which is the one loud way to keep a value out of both. sensitive combines with skip_encoding_if — the predicate may omit more than masking would.

The placeholder

One text stands in for every masked string: REDACTED_PLACEHOLDER, spelled [REDACTED]. It is what a fixture stores for a sensitive string lane, what a serialized header shows in place of a credential, what the seal writes where a signature would go, and what a failure report prints — one text, so a fixture, a log line, a report, and a scanner all name the same thing. The other lanes have their own constants: 0 for numbers, false for booleans, empty bytes for a byte field.

An author who wants something else says so inside the class:

#[wire(sensitive = "sk-[REDACTED]")]             pub key: String,     // a literal for the byte plane
#[wire(sensitive(mask = "sk-[REDACTED]"))]       pub key: String,     // the same, spelled long
#[wire(sensitive(redacted = "\"anon\""))]        pub account: String, // a typed placeholder expression
#[wire(sensitive(redacted_fn = "mask_email"))]   pub email: String,   // fn(&T) -> T, computed from the value

mask and the two redacted keys fill slots on different planes — the next section — and redacted / redacted_fn share one slot: a sensitive field has one typed placeholder. Nesting the keys inside sensitive(...) is what keeps a placeholder inseparable from the class it describes.

Two planes, chosen by direction

Two mechanisms mask a sensitive value, and which one runs is decided by the direction of the message, not by preference.

PlaneRuns whenReachesPlaceholder keys
Typed — the Redact transforma model exists before there are bytes: requestswhatever the model declaresredacted / redacted_fn
Byte — the recorder’s maskingonly bytes exist: responses, streams, undeclared secrets, non-model bodieswhatever the decoded tree holdsmask (= sensitive = "…")

A response arrives from the server as bytes. There is no model instance to call .redacted() on, and decoding to redact and re-encoding would replace the server’s actual bytes in the fixture — so the byte plane is the only mechanism on the response side, permanently. The typed plane owns the request side, where a request can be born redacted instead of scrubbed afterwards; it is also the only plane that can catch an undeclared secret, because a born-redacted request has nothing left to learn from.

The practical rule for choosing a key: on a request model, reach for redacted / redacted_fn, and a bare sensitive is usually enough; on a response model, reach for sensitive = "…" when the lane default will not do — the typed keys are inert there. A model used in both directions annotates for both, sensitive(mask = "XXX", redacted = "…"), and the two vocabularies never reinterpret each other.

The typed transform: Redact

capi_wire_model::Redact is the consuming transform on a typed value:

pub trait Redact: Sized {
    /// Plain position: keep the value, redact whatever it declares sensitive inside.
    fn redacted(self) -> Self;
    /// Sensitive position: the placeholder form, every leaf masked.
    fn redacted_placeholder(self) -> Self;
}

Two methods, because one cannot answer both questions about the same value: a plain String field must survive redaction untouched, while a sensitive one must become the placeholder. Both consume and return Self, so redaction is a move — nothing is cloned, and what comes out is the type that went in. Leaves are identity in plain position and the lane’s constant in sensitive position. Containers map, preserving presence and element count: a masked Some stays Some, because absence hides nothing, and a masked sequence keeps its length, because that is the shape its binding needs to decode again. A map’s keys are structure, not payload, and are left alone.

#[derive(WireModel)] emits a Redact impl for every model, recursing field-wise and replacing its own sensitive fields with their placeholders — which has a compile-time consequence: every non-skip field’s type must implement Redact, or the derive refuses. Derived models, the primitives, Option, Vec, BTreeMap<String, _>, Tristate, Bytes, and the datetime and decimal types all do; a scalar type of your own adds two identity methods when it holds nothing sensitive, or a real placeholder when it does (the escape hatches chapter shows the shape). A model that declares nothing sensitive redacts to itself, and a fieldless enum has no content to replace — most types need no redaction, and are their own placeholder.

Two types worth knowing the placeholders of: a FileBlob in sensitive position becomes an empty in-memory blob with its file name masked — the path or browser handle would not resolve on the machine reading a fixture back, and the name is often the most identifying thing about an upload — while its MIME type survives, because a multipart body whose parts lost their types cannot round-trip. And a number quoted as text masks to "0" on the byte plane (parseable) against the field’s own scale on the typed plane; the test framework inventories such divergences rather than hiding them.

The framework’s verb: ApiRedact

#[wire(...)] belongs to the wire model, and Capi never parses it. What the framework owes its users is a way to name the redaction step as part of the framework, uniformly for every endpoint. ApiRedact, reachable through the prelude, is that name:

let twin = endpoint.clone().redacted();          // plain position
let placeholder = endpoint.clone().redacted_placeholder();

A blanket impl lifts every Redact implementor onto it at zero authoring cost, so an endpoint that derives WireModel beside ApiEndpoint gets it for every routed field — path fields included, so path_string() interpolates the masked value, and the query, header, and body proxies read masked values through code that knows nothing about redaction. That is why the endpoint is redacted before any proxy is built from it: a borrowed proxy holds a reference to the endpoint’s field, so a redacted = .. placeholder written against the field’s own type is dropped when the attribute is forwarded onto the proxy, and the proxy keeps only the class. An endpoint that is not a wire model — one whose fields route through proxies with no wire binding of its own — hand-implements ApiRedact instead; the two populations partition every type, so a hand-written impl cannot collide with the blanket.

Credentials and signatures

The seal places credentials and the carrier signs after every member has run, so neither is a field a model could declare. They take the same stance by a different route. An endpoint’s Auth::DEFAULT.redacted(), or a send’s RequestOverrides::redact_credentials(), keeps the shape of the request’s authentication — the headers and query parameters a live placement would write, the headers a signer declares as its outputs() — and writes REDACTED_PLACEHOLDER where each value would go. Nothing is renewed to arrive there and no custody operation runs, so the request never holds a credential at all. Auth::NONE stays NONE: there is nothing to redact.

The twin

Put the two together and a request can be recorded without ever having held a secret. The test framework’s api.query_redacted(endpoint) clones the endpoint, redacts one clone through ApiRedact, finalizes it under redact_credentials so the seal places placeholders, and stashes that twin on the live request’s head. The live request goes to the wire; the twin is what the fixture holds and what replay compares. Nothing in it ever held a live value, so nothing is decoded or searched to mask it. The endpoint must be Clone and ApiRedact, which a WireModel endpoint is. Fixtures and Sensitive Data places the twin among the recorder’s other tiers.

Next: Format Extensions — the per-format attribute vocabularies the contract crates add.