Autumn's other protections for sensitive data are name-based and run at
runtime. The log scrubber matches a key denylist
(password, ssn, credit_card, …), the HTTP client redacts three header
names, and the GDPR registry tracks erasure by table-name strings. Every one of
them is one rename away from silently letting personal data through, because the
classification lives in the developer's memory rather than in a type.
#[classified] moves one tier — personal data — into the type system, and
gates one sink — the Json response — on it. A leak stops being
a production incident and becomes a build failure.
#[autumn_web::model(table = "customers")]
pub struct Customer {
pub id: i32,
pub name: String,
/// Personal data: released only through a declared boundary.
#[classified]
pub email: String,
}
That one attribute changes three things:
- the generated field is
Classified<String, CustomerEmailClassified>, notString; Customerno longer implementsSerialize, so it cannot be handed to a sink;- the column and the boundaries that release it appear in the
data-flow manifest
autumn data-flowemits.
What stops compiling
Handing the whole record to the JSON sink:
async fn show(Path(id): Path<i32>) -> AutumnResult<impl IntoResponse> {
let customer = CustomerRepository::find(id).await?;
Ok(Json(customer)) // ❌
}
error[E0277]: `Customer` cannot be released into autumn's `Json` response sink
= note: an autumn `#[model]` with `#[classified]` columns has no `Serialize`
impl: the classification is carried by the type, so the whole record
cannot reach a sink
= note: release each classified column at a declared boundary and respond with
the released view
Lifting the column into a response DTO — the leak a rename or a new endpoint would otherwise reopen:
#[derive(Serialize)]
struct SupportView { email: String }
SupportView { email: customer.email } // ❌ expected `String`,
// found `Classified<String, CustomerEmailClassified>`
And putting the wrapper itself in a serializable struct:
error[E0277]: `CustomerEmailClassified` is classified personal data and cannot be
serialized into a sink
= note: declare a boundary with `autumn_web::declassify!` and release the value
first: `value.declassify(&YOUR_BOUNDARY)`
The wrapper has no Serialize, no Display, no Deref, no Hash and no
into_inner, so no expression hands the value — or a serializable view of it —
to a sink. That is the guarantee: it is a property of the type, not a list of
field names.
Two pieces of surface survive on purpose, and both are boolean: PartialEq,
which the generated repository code needs to correlate records and to compute an
UpdateDraft's changed fields, and the validator rules below, which answer
true/false over the real value. An author who writes a loop around
== or validate_regex can narrow a value down without a boundary. That is
accepted: the threat model here is drift detection, not an author attacking
their own application — the same posture
the security posture manifest states — and such
an author could simply declare a boundary instead. What is closed is every path
that leaks the value by accident.
Declassifying at a boundary
A release is declared, not incidental. declassify! names the column, the
sink, the purpose and the reason:
autumn_web::declassify! {
/// Support agents need the customer's email address to answer the ticket.
pub SUPPORT_LOOKUP: CustomerEmailClassified => JsonResponse,
purpose = "support_lookup",
reason = "Support agents need the email address to answer the ticket.",
}
CustomerEmailClassified is the marker #[model] generated for the column
({Model}{Column}Classified). It types the boundary, so one column's approved purpose
cannot release another's:
customer.phone.declassify(&SUPPORT_LOOKUP) // ❌ expected Declassification<CustomerPhoneClassified>
Both purpose and reason must be non-blank string literals — a boundary whose
justification is three spaces is the one nobody can review, so the macro rejects
it at compile time.
The approved path:
#[derive(Serialize)]
struct SupportView {
name: String,
email: String,
}
async fn show(Path(id): Path<i32>) -> AutumnResult<impl IntoResponse> {
let customer = CustomerRepository::find(id).await?;
Ok(Json(SupportView {
name: customer.name,
email: customer.email.declassify(&SUPPORT_LOOKUP),
}))
}
declassify takes the value by move, so a release is a single event rather
than a permanent widening. When the record is borrowed, declassify_cloned
releases a copy and records identically.
The auditable record
Every release emits a tracing event on the autumn::declassification target
carrying the model, field, tier, purpose, sink and reason. It deliberately does
not carry the released value — recording the plaintext would reintroduce the
leak the type system just closed.
Applications that persist the record install an observer:
let _guard = autumn_web::classify::capture_releases(|record| {
tracing::warn!(?record, "personal data released");
});
The guard removes the observer when it drops.
The data-flow manifest
autumn data-flow --manifest target/autumn/data-flow-manifest.json
A bare cargo build does not write the manifest, and cannot: reachability is a
whole-app fact that only exists once everything is linked. autumn data-flow
builds the app, runs it in dump mode, and emits one row per classified column
listing every sink it is proven reachable to:
{
"schema_version": 1,
"gated_sinks": ["json_response"],
"fields": [
{
"model": "Customer",
"model_path": "shop::models::customer@2::Customer",
"field": "email",
"classification": "personal_data",
"reachable_sinks": [
{
"sink": "json_response",
"purpose": "support_lookup",
"reason": "Support agents need the email address to answer the ticket."
}
]
},
{
"model": "Order",
"model_path": "shop::models::order@1::Order",
"field": "card_number",
"classification": "personal_data",
"reachable_sinks": []
}
]
}
Rows are keyed on model_path, not the display name: two crates can each define
a Customer with a classified email, and merging them into one row would hide
one model's column behind the other's. The path has the compatible version of
the defining crate. @2 means major version 2. @0.8 means version 0.8 (a
crate before 1.0). Two incompatible versions of one crate can link into one
binary. They get separate rows. A patch release does not change the path.
A crate before 0.1 is the exception: its path has the patch (@0.0.2).
A prerelease has its full version in the path (@1.0.0-alpha.1). The
report prints the short name unless two rows share it.
An empty reachable_sinks means no leak: nothing in the binary declares a
boundary for that column, and a boundary is the only way to a gated sink.
The reachable set is an over-approximation in the safe direction: a row lists every sink a declared boundary could release the column to, not the call sites that actually do. A boundary declared and never used still shows up. That is deliberate — a manifest that under-reports a reachable sink would be worse than no manifest.
Why it runs the binary rather than parsing sources: reachability is a whole-app
fact. A column declared in one crate can be released by a boundary declared in
another, or in a plugin the app merely depends on, and link-time collection is
the only place all of those registrations exist together. This is the same shape
as autumn routes audit and
autumn cache audit.
There is no gate in autumn data-flow, on purpose: the compiler is the gate.
What the manifest gives you is a diff. Commit it and check it in CI, so a new
release edge has to be approved rather than merged silently:
autumn data-flow --check data-flow-manifest.json
Audit under the profile you deploy. The manifest describes the binary that
produced it, and by default that is the debug binary. cfg!(debug_assertions)
differs between profiles, so a classified column or a declassification boundary
behind #[cfg(not(debug_assertions))] exists only in the release build — and a
--check against a debug-built manifest would pass while the shipped binary
carried release-only edges nobody reviewed. If you deploy --release, commit
and check the release manifest:
autumn data-flow --release --manifest data-flow-manifest.json # to record
autumn data-flow --release --check data-flow-manifest.json # in CI
The same applies to --features: a column or boundary behind a feature the
build does not enable is not compiled in, so it cannot appear in the manifest.
Audit with the feature set you ship.
✗ The data-flow manifest has drifted from the committed copy:
~ Customer.email reaches json_response for support_lookup
-> json_response for marketing_export, json_response for support_lookup
What #[classified] does not change
-
The name-based redaction still runs.
log/filter.rsand the HTTP client's header denylist are untouched; classification composes with them rather than replacing them. -
The write path still accepts personal data. Taking a classified value in is what an application does, so
NewCustomer,UpdateCustomerand the changeset all still deserialize the column, run its#[validate]rules and render its form field. What changed is the field's type: they holdClassified<String, CustomerEmailClassified>(the patch holdsPatch<Classified<…>>), not a bareString.Those fields are
pub, so a bareStringwould have been a way around the whole guarantee — a handler could writeJson(View { email: input.email })and release the value with no boundary and no audit record. Excluding the column from the write struct's ownSerializedoes not help, because that governs serializingNewCustomer, not moving a value out of it.In practice this costs one
.into()when you build a write struct by hand:Rustlet new = NewCustomer { name: form.name, email: form.email.into(), };Decoding a request body needs no change at all —
Json<NewCustomer>andForm<NewCustomer>deserialize exactly as before.The generated factory carries it too, for the same reason: its fields are
pub, soCustomer::factory().emailwould otherwise be a way straight out. Its setter is unaffected — it still takes the plain type, so.email("[email protected]")with a bare&strworks as before, and the value is classified from the moment it is stored. -
Debugis redacted on the model and on the write structs — the value renders as<classified>, so it cannot reach a panic message or an error page. -
#[validate]still runs on the classified column. The wrapper forwardsvalidator's string rules (email,length,contains,does_not_contain,url,regex,ip) to the inner value, answering each with abool.ValidateEmail::as_email_stringandValidateUrl::as_url_string— the two rules whose trait shape hands the value back rather than a verdict — returnNoneon the wrapper, so the rule still evaluates on the real value while nothing can read it back out. -
The database sees a plain
Textcolumn. Classification says where a value may go, not how it is stored. For at-rest protection, that is#[encrypted], or, where the operator must not read the value at all,#[confidential]. The column round-trips throughClassifiedText<CustomerEmailClassified>, which carries the same field marker as the value: anF-erasing column type would have been a way to convert a value in as one classified column and back out as another, releasing it through the wrong boundary and recording it against the wrong column. -
The column drops out of the client-controlled list surface. A repository's
list()allowlists every scalar column for?filter[col]=and?sort=col. A classified column is excluded from both: the filter would be an unauthenticated equality oracle over the personal data and the sort would order the result set by it, neither through a boundary nor visible in the manifest. Filtering or sorting on it is simply ignored, exactly as for any unlisted key.
Restrictions in this slice
#[classified] applies to non-null String columns and rejects, with a
diagnostic that says why:
| Rejected with | Because |
|---|---|
a non-String type | v1 has one Diesel column representation |
#[encrypted] | both own the column's Diesel representation |
#[searchable] | the search vector is not a gated sink |
#[normalize] | normalizing needs the plaintext with no boundary to record it |
#[translatable] | a per-locale container is a document, not a value |
#[id], #[lock_version], #[position], #[state_machine] | framework-managed columns |
#[serde(rename)] / #[serde(rename_all)] | the manifest is keyed on the Rust name |
#[serde(with/serialize_with/deserialize_with)] | the adapter is written against the declared type, and a serialize_with serializes the withheld value |
the tenant_id column | the tenancy codegen reads it directly as an isolation key |
Two further limitations are worth knowing before you classify a column:
- A
#[repository]that records version history or is ledgered serializes the whole model into its snapshot, so it does not compile against a classified model (the snapshot needs theSerializeimpl the classification withholds). A repository with durable commit hooks compiles, but building the payload returns an error at runtime naming the model and the columns. Both of those payloads are second, unclassified copies of the record — gating them is a follow-up slice. For now, do not classify a column on a versioned, ledgered, or durable-commit-hook model. - Only the JSON response sink is gated. Log/tracing events, outbound HTTP bodies and analytics emission are follow-up slices — for those, the runtime name-based scrubbing is still what protects you.
- Anything else in the framework that needs the model to serialize needs a
released view instead, and says so at build time:
form_for::<Customer>and#[repository(..., api = "/customers")]are the two you are most likely to meet. The generated REST handlers assert the sink bound directly, so the failure names the model and points here rather than surfacing as an axumHandlerbound. - A
#[repository(..., cursor_key = <classified column>)]does not compile: a cursor is echoed back to the client verbatim, which is a release with no boundary. Page on a non-classified column.
Adding a second tier or a second sink
Both are additive by construction. A tier is a variant of
classify::Classification plus a spelling accepted by #[classified(...)]. A
sink is a variant of classify::Sink plus a marker trait alongside
classify::JsonSink, bounded on the sink's entry point the same way
Json's IntoResponse is.