Sanitize U+0000 (NUL) at the JSONB_VARIANT type boundary#
Status: accepted
Date: 2026-06-23
Related: ops-db#110, ops-db#107 (existing-row backfill migration), ops-db#100, data-transfer#173 (regression test), ADR-0002 (
JSONB_VARIANTintroduction)
Context#
PostgreSQL jsonb cannot store a NUL (U+0000). A value carrying a \x00
anywhere in its string content raises on insert:
DataError: unsupported Unicode escape sequence ... � cannot be converted to text. Plain json and SQLite tolerate the byte, so the failure is invisible
in the SQLite test suite and only fires on the Postgres backend (staging/prod).
The bytes arrive through legitimate writers: error breadcrumbs (error_context),
tracebacks, captured stderr, and arbitrary external strings can contain a NUL.
After ADR-0002 routed ~28 columns through a single JSONB_VARIANT type, every
one of those columns is exposed to this failure. Defending per-writer would mean
remembering to scrub at N call sites across data-transfer, ops-db-api,
workflow-manager, and obs_implementation — exactly the kind of distributed
obligation ADR-0002 set out to remove.
Decision#
Enforce the constraint once at the type boundary. JSONB_VARIANT is now a
TypeDecorator (NulSafeJSONB, cache_ok = True) wrapping the existing
JSON().with_variant(JSONB(), "postgresql") impl. Its process_bind_param
recursively walks the bound value and replaces every U+0000 in a str (values
and dict keys) with the sentinel ␀ (U+2400). The public name JSONB_VARIANT
and the rendered DDL (jsonb on Postgres, JSON on SQLite) are unchanged, so
models.py needs no edits.
A module-level NUL_SENTINEL = "␀" constant is exported for reuse by the #107
existing-row backfill migration and the data-transfer#173 regression test.
Sentinel ␀ vs. bare stripping#
NUL is replaced, not dropped. ␀ (SYMBOL FOR NULL) preserves the diagnostic
location of the offending byte — an operator reading an error_context still
sees where the control byte was, instead of two strings silently fused. The
sentinel is an ordinary printable character, harmless in any consumer.
All dialects, not Postgres-only#
The scrub runs on every dialect. SQLite stores NUL natively and would not need it, but gating on Postgres would mean the SQLite test suite and local dev never exercise the scrub, reintroducing the dev/prod divergence that hid the original bug. Running it everywhere gives parity and lets the SQLite suite prove the behavior.
Cheap clean path#
The common case is NUL-free. The decorator first does a cheap recursive scan
("\x00" in s on the raw Python strings); only if a NUL is actually present does
it build a scrubbed copy. The caller’s object is never mutated in place.
NUL detection is performed on the raw Python strings during the walk, not on
json.dumps(value): json.dumps escapes NUL to the six-character text
�, so a real chr(0) never survives into the dumped text and a
"\x00" in dumped scan would always be False — a silent miss. Detecting on the
live strings is the only correct option.
Visible, not silent#
When it substitutes, the decorator logs a WARNING with the replacement count and
the top-level dict keys affected. The column name is not available inside
process_bind_param, but count + keys give an operator enough to trace which
writer emitted the illegal byte. Clean data logs nothing.
Considered and rejected#
Scrub per writer — defeats the single-chokepoint rationale of ADR-0002; every new writer is a fresh chance to forget, and the failure only surfaces on Postgres.
Bare stripping of NUL — loses the diagnostic location and can silently fuse adjacent tokens; the sentinel costs nothing extra.
Postgres-only gate — keeps the SQLite suite blind to the scrub, perpetuating the dev/prod gap.
Detect via
json.dumpsscan — wrong: NUL is escaped to�text and never appears as a real char in the dump.
Consequences#
Every jsonb column is NUL-safe on write with zero per-writer effort; a NUL can no longer crash an insert on Postgres.
Existing rows written before this change may already hold a raw NUL (only possible on SQLite / pre-jsonb data); ops-db#107 backfills them to
NUL_SENTINELusing the exported constant.A NUL surfacing as
␀in stored data is an intentional, traceable signal (with a WARNING in the logs), not data corruption.The clean path adds one recursive
in-scan per write; negligible for the small breadcrumb/config payloads these columns hold.