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>
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 |
|
|
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.