ADR-0026: Sanitized String Values Satisfy Their Field's Length Bounds
Status: Accepted
Date: July 2026
Context
String and Text fields sanitize their input by default (sanitize=True),
running bleach.clean(). Sanitization changes the length of the value in both
directions: it lengthens by HTML-escaping (& → &, < → <,
> → > — one character becomes up to five), and it shortens by stripping
HTML comments and disallowed attributes.
min_length and max_length are Pydantic core constraints, so they run on the
raw input before sanitization. The value that actually gets stored is the
sanitized one. Those two forms diverge whenever sanitization changes the
length, and the consequence is a broken round-trip:
String(max_length=50)given"&" * 11passes the length check (11 ≤ 50) and stores"&" * 11— a 55-character value.String(min_length=8)given"hi<!-- a long comment -->"passes (25 ≥ 8) and stores"hi"— a 2-character value.- A required
String(implicitmin_length=1) given"<!--x-->"passes and stores"".
Re-validating any of those stored values against the same field — on a serialization round-trip, or when an event-sourced aggregate replays its stream — fails. For an event-sourced aggregate this is a silent-corruption path: the event is accepted and committed, but the aggregate can no longer be reconstituted from its stream. A committed event you cannot replay is data loss.
A related failure affects choices. A String(choices=[...]) compiles the
choices into a Literal core constraint checked on the raw input, but
sanitization then mutates the stored value so it no longer matches any choice —
so the object holds an invalid value immediately, and replay fails. (Status
fields are not affected: they default to sanitize=False.)
bleach.clean is idempotent (clean(clean(x)) == clean(x); it recognizes
existing entities and does not re-escape &, and re-stripping an
already-stripped string is a no-op), so there is no double-transform corruption —
the stored value is stable. The only defect is that the field accepts values it
will not later re-accept.
The bug was found by the property-based verification suite added in #1252
(epic #1199): a replay-determinism property generated a name with </& near
the length boundary and from_events raised.
Decision
The sanitized (stored) value must satisfy its field's length bounds and choice set, so a value accepted on write always round-trips. Two changes:
-
Length bounds are re-checked on the sanitized value. The sanitization
AfterValidatoronString/Textfields now also enforcesmin_lengthandmax_length(including a required field's implicitmin_lengthof 1) on its output. A value whose sanitized form falls outside the bounds is rejected at write time with a clear message ("String has N characters after sanitization, exceeding max_length of M"/"... below min_length of M") rather than being stored in a form the field rejects on the way back. The core constraints still run first on the raw input (so plain out-of-bounds input keeps its standard error) and still drive the JSON Schema and database column length. -
choicesfields are not sanitized. Whenchoicesis set, sanitization is skipped entirely: the value must match a declared choice exactly, the choice set is a closed developer-defined vocabulary, and HTML-escaping it would break the match. The stored value is the raw choice, which round-trips.
Fields with sanitize=False are unaffected: their stored value equals their raw
input, so the bounds already apply to what is stored.
Consequences
- Round-trip and replay hold for accepted values. A value a field accepts can be serialized, stored, and replayed without a length or choice error. No committed event can be unreplayable because sanitization moved the stored value out of bounds.
- Some writes that previously succeeded now fail. Input whose sanitized form
falls outside the length bounds is rejected instead of silently stored. These
are precisely the values that produced unreplayable data. The remedy is to
widen the bound or set
sanitize=False. - A value is accepted only if both its raw and sanitized forms satisfy the
bounds. The core constraint still checks the raw input, and the new check
covers the sanitized form, so acceptance is the intersection. Because escaping
only grows the value and stripping only shrinks it, this can over-reject at the
edges — e.g. a raw input just over
max_lengthwhose stripped form would fit is still rejected on its raw length. This is fail-safe (rejection, never corruption) and is documented behavior, not a claim that the bound applies to the stored value alone. choicesfields change behavior. A sanitizedchoicesfield previously stored an escaped value that no longer matched its own choice list (a latent bug); it now stores the raw choice. Escapable characters in a choice value are no longer HTML-escaped — acceptable because the vocabulary is developer-defined, not free user input.- Existing streams already holding an out-of-bounds value are not healed. This
change prevents new unreplayable data; it does not retroactively fix a stream
that already contains one. Those values must be migrated (widen the bound, or
re-key with
sanitize=False). In practice this population is expected to be empty: the defaultmax_lengthis 255 and it takes escapable characters near the limit to overflow. - The guarantee rests on
bleachidempotency. On replay the stored value is sanitized again; only becauseclean(stored) == storeddoes its length stay in bounds. A future non-idempotent sanitizer or config would reopen the round-trip hole; the fix checks length against a single sanitize pass.
Alternatives Considered
- Fix only
max_length(the originally reported symptom). Rejected: the same raw-vs-stored divergence breaksmin_length, a required field's implicit minimum, andchoices— all the same silent-corruption / unreplayable class. Fixing one manifestation and leaving the others is not a real fix. - Trusted deserialization: skip re-validation when rehydrating stored data. Treat validation and sanitization as boundary concerns for fresh input only, and bypass them when reconstituting trusted stored data. This heals existing streams and changes no write behavior, and is conceptually clean ("do not treat your own store as an attacker"). Rejected as too large and risky: it means threading a "trusted" path through the deserialization choke point, which also carries lenient-mode, alias, and coercion logic, and disabling re-validation there weakens a genuine guard.
- Run sanitization as a
BeforeValidatorso the core constraints see the stored value. This would collapse the length bounds to a single source of truth (no re-check). Rejected: aBeforeValidatorruns before type coercion, so it would sanitize the raw input before it is a string — abytesvalue would be decoded tostrby the core after sanitization and never get sanitized, a security regression. Keeping sanitization post-coercion and re-checking the bounds avoids that hole. It also forfeits the diagnostic error message (the generic "at most N characters", confusing for an input that was well under the limit before escaping). - Measure the pre-escape (semantic) length on read. Not reliably
implementable: sanitization is not reversible (
&could be an escaped&or a literal&; a stripped comment cannot be recovered), so the original length cannot be reconstructed from the stored value.