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
}
| Class | Meaning | Recorded | Compared |
|---|---|---|---|
Plain (default) | an ordinary value | in full | in full |
volatile | non-deterministic — timestamps, nonces, request ids | verbatim | by presence and lane only |
sensitive | secret | masked | by 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.
| Plane | Runs when | Reaches | Placeholder keys |
|---|---|---|---|
Typed — the Redact transform | a model exists before there are bytes: requests | whatever the model declares | redacted / redacted_fn |
| Byte — the recorder’s masking | only bytes exist: responses, streams, undeclared secrets, non-model bodies | whatever the decoded tree holds | mask (= 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.