Files
fusion/docs/solutions/reliability/chat-jsonb-nul-sanitization.md
gsxdsm b9c5df04af FN-8791: prevent NUL-containing chat checkpoints from crashing
Prevent malformed tool output from breaking chat checkpoint persistence.

- Sanitize NUL characters at chat JSONB persistence boundaries without mutating clean values.
- Observe rejected best-effort checkpoint writes so streaming sessions do not emit unhandled rejections.
- Add PostgreSQL, dashboard, and sanitizer regression coverage with operator-facing release notes.

Files changed:
 .changeset/fn-8791-chat-nul-checkpoint.md          |  7 ++
 .../reliability/chat-jsonb-nul-sanitization.md     | 44 +++++++++++
 packages/core/src/__tests__/nul-sanitize.test.ts   | 21 ++++-
 .../chat-store-content-search-edit.pg.test.ts      | 91 +++++++++++++++++++++-
 packages/core/src/async-stores/async-chat-store.ts | 61 ++++++++++-----
 packages/core/src/postgres/nul-sanitize.ts         | 17 ++--
 .../dashboard/src/__tests__/chat-manager.test.ts   | 90 +++++++++++++++++++++
 packages/dashboard/src/chat.ts                     | 21 ++++-
 8 files changed, 322 insertions(+), 30 deletions(-)

Fusion-Task-Id: FN-8791

Fusion-Task-Lineage: 51e13634-04cc-4827-b72f-f33e40760940

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-04 19:15:20 -07:00

2.9 KiB

title, date, problem_type, module, component, tags, symptoms, root_cause, resolution_type
title date problem_type module component tags symptoms root_cause resolution_type
Chat JSONB persistence must strip U+0000 at every PostgreSQL boundary 2026-08-05 reliability @fusion/core chat-persistence
postgres
jsonb
chat
nul
checkpoints
unsupported Unicode escape sequence during chat session checkpointing
unhandled rejection after a tool reads NUL-bearing output
Raw model and tool text reached PostgreSQL jsonb bindings without U+0000 sanitization, while fire-and-forget checkpoint promises had no rejection observer. code_fix

Problem

PostgreSQL rejects U+0000 in text and jsonb. Chat tool results are arbitrary environment-controlled output, so a tool reading redirected Fusion logs can include the logger marker \u0000fnlvl=info\u0000. Persisting that result in an in-flight generation snapshot caused the checkpoint write to reject; because checkpoints are intentionally fire-and-forget, the dashboard then emitted an unhandled rejection.

Protected surfaces

The invariant belongs at PostgreSQL persistence boundaries, not provider callbacks. sanitizeJsonbValue now recursively strips U+0000 from JSONB values and keys before chat-session snapshot creation/checkpoint writes, message and room-message inserts, attachment appends, and message metadata merges. This covers direct chat, QuickChat, all provider callbacks, and future tool providers without mutating the caller's value. Clean values retain identity; null and undefined retain their existing semantics.

Degradation behavior

In-flight checkpoints are crash-recovery state, not turn control flow. ChatManager routes debounced timer writes and immediate flushes through one observing helper. A failed checkpoint emits a concise session-scoped warning without payload or database-error text, drops that attempt, and allows streaming, completion, cancellation, and later clears to continue. It does not retry, wait, or create another scheduler.

Why strip U+0000

Stripping is deterministic, preserves the readable surrounding tool output, and is already the established PostgreSQL sanitizer behavior. Escaping into a literal sequence would change tool text semantics and still require every reader to understand a special encoding.

Regression commands

pnpm --filter @fusion/core exec vitest run src/__tests__/nul-sanitize.test.ts --silent=passed-only --reporter=default
pnpm --filter @fusion/core exec vitest run src/__tests__/postgres/chat-store-content-search-edit.pg.test.ts --silent=passed-only --reporter=default
pnpm --filter @fusion/dashboard exec vitest run src/__tests__/chat-manager.test.ts --project dashboard-api-quality --reporter=default --silent=passed-only

The PostgreSQL test persists the reported full in-flight tool-result shape and reads it back; dashboard tests exercise both debounced and flush rejection paths with no unhandled rejection.