
Отчёт пользователя вскрыл утечку между диалогами в query-провайдере ядра — 6 из 6. Что ещё в 3.6.0: AS2/EDI, общий Kestrel, Camel-паритет.
Год стек рос на наших собственных задачах: мы писали то, что нужно было нам, и проверяли на своей проде. С весны им начали пользоваться посторонние люди — и характер входящих сообщений изменился. Вместо «а поддерживаете ли вы X» приходят разборы: воспроизведение, номера строк в наших исходниках, обходные пути, которые человек уже написал у себя, пока ждал ответа.
Это самое ценное, что может случиться с проектом. Спасибо всем, кто не поленился написать вместо того, чтобы молча уйти на другую библиотеку. Один такой отчёт лежит в основе половины этого релиза — и вскрытый им дефект оказался вовсе не там, где его нашли.
Итак, redb 3.6.0: ядро, интеграционный движок redb.Route, рантайм redb.Tsak и OIDC-сервер redb.Identity — все на одном номере, 63 пакета.
Дефект, который был на два слоя ниже, чем казался
Разработчик чат-продукта на redb.Route.Llm прислал: «первый ход нового человека приезжает в модель с историей другого человека, а его первое сообщение прицепляется в чужое дерево».
Звучит как баг LLM-коннектора — истории диалогов хранятся деревьями, что-то напутали с загрузкой ветки. Мы пошли смотреть и обнаружили, что коннектор ни при чём.
В query-провайдере ядра WhereLeaves() замещал корневой CTE вместо того, чтобы работать предикатом поверх него. То есть:
// Намерение: листья ЭТОГО дерева var leaves = await redb.TreeQuery<MessageProps>(root) .WhereLeaves() .ToListAsync();
на деле означало «самые свежие листья схемы во всей базе». Фильтра по корню в запросе просто не оставалось.
Дальше арифметика простая. В многопользовательском проде «самый свежий лист схемы» — это транскрипт того, кто писал последним глобально. Не только на первом ходу: на любом. Все диалоги схлопываются в одну ленту. И хуже: следующее сообщение прицепляется родителем в чужое дерево, то есть данные не только читаются криво — они криво пишутся.
Дефект оказался кросс-провайдерным: шесть из шести — Postgres, MSSql, SQLite, каждый в Free и Pro. Чинили одинаково на всех: tree_leaves/tree_roots теперь засеиваются от корня, а не подменяют его.
Отдельная часть этой истории — SQLite. Free-тир исполняет запросы в нативном расширении, и правка живёт в redb_pvt.c, а не только в C#. Пакет со старыми бинарями отдал бы исправление на managed-стороне и оставил утечку в Free — молча, без единой ошибки в логе. Поэтому расширение пересобрано под все три RID (win-x64, linux-x64, linux-arm64), и это проверено сверкой хешей против предыдущего релиза, а не по датам файлов: размеры linux-сборок совпали до байта, и метки времени тут ничего не доказывали.
Мораль, которую стоит забрать даже если вы не пользуетесь redb: фильтр, который «не сработал», и фильтр, который «заменил собой область поиска», выглядят в коде одинаково, а на данных различаются катастрофически. Второй не даёт ошибки — он даёт правдоподобный ответ не про то.
Второй дефект из того же отчёта: инструмент без контекста
Формулировка автора: «тулза получает обмен без контекста запроса — и единственный простой способ дать ей user id или tenant — просить модель передать его аргументом, что открывает prompt-injection-эскалацию».
Это точная постановка проблемы безопасности. Если инструмент узнаёт, от чьего имени его вызвали, из аргумента, который сформировала модель, — значит достаточно уговорить модель подставить туда чужой идентификатор.
Что было: движок копировал в обмен инструмента жёстко зашитый список из трёх заголовков и ничего больше. Что стало:
маршруты инструментов исполняются дочерним обменом агентского, а не «голым» — контекст, скоуп и трассировка наследуются;
принципал и аудит-теги доезжают до инструментов — причём как значения, вычисленные для прогона, а не как сырые заголовки. Разница существенная:
?user=${header.X-User-Id}и?audit=разрешаются движком, и простое копирование заголовков их бы не поймало;добавлен
.PropagateToolHeaders(...)— явный список дополнительных заголовков, которые нужно пробросить;*на конце работает как префикс. Список по умолчанию пуст: проброс — осознанное решение, а не поведение «на всякий случай».
route.From("http://0.0.0.0:8080/chat") .Llm("llm://openai/gpt-4o?user=${header.X-User-Id}&audit=tenant:${header.X-Tenant}") .PropagateToolHeaders("X-Tenant", "X-Request-*") .To("direct://reply");
Именно из-за этого метода релиз минорный, а не патч: новая публичная поверхность номером патча не выпускается.
AS2 и EDI: обмен документами с партнёрами прямо в маршруте
Крупная розница, 3PL-операторы, банки с host-to-host каналом, автопром — двадцать лет обмениваются заказами, счетами и уведомлениями об отгрузке по AS2: подписанный и зашифрованный S/MIME поверх HTTP с подписанной квиткой в ответ. В .NET до сих пор это означало коммерческий шлюз или Java-сервер сбоку от вашей интеграции.
Теперь AS2 — обычный коннектор redb.Route, схемы as2: и as2s:. Обе стороны, синхронный и асинхронный MDN, подписанная квитанция, полная матрица алгоритмов (sha-1/256/384/512 × aes-128/192/256-cbc, 3des, опциональное сжатие RFC 3274). Криптография — MimeKit поверх Bouncy Castle, то есть тот же фундамент, на котором AS2-индустрия и совместима.
Интероперабельность проверена против живого OpenAS2 v4.9.0 в обе стороны — подписанное и зашифрованное сообщение, положительный MDN, сверка MIC.
Подробный разбор с маршрутами, партнёрскими профилями и разницей со шлюзом — в отдельной статье про AS2.
Один порт на несколько коннекторов
AS2-приёмник — это HTTP-сервер. У вас уже есть HTTP-маршруты. Партнёр хочет один эндпоинт, TLS терминируется один раз — значит и то, и другое должно жить на одном порту.
Раньше мультиплексор Kestrel (один сервер на пару host:port) лежал внутри redb.Route.Http. Чтобы AS2 мог им пользоваться, ему пришлось бы зависеть от коннектора общего назначения — а это тянет в приложение весь HTTP-транспорт ради одного класса и выворачивает направление зависимостей наизнанку.
Поэтому SharedHttpServerManager вынесен в отдельный пакет redb.Route.Http.Hosting. Оба коннектора зависят от него, друг от друга — нет; регистрация идемпотентна, каждый резолвит один и тот же синглтон. Типы остались в namespace redb.Route.Http, так что существующий код править не нужно.
Здесь стоит рассказать честную часть, потому что она поучительна. Вынос приехал уже после того, как redb.Route.Http был опубликован на nuget.org. Заменить опубликованную версию нельзя. А redb.Route.As2 ссылается на Hosting, но не на Http — значит резолвер NuGet никогда бы сам не поднял Http до исправленной версии, и у пользователя связки «старый Http + As2» тихо оказались бы два независимых Kestrel: ровно та проблема, ради которой вынос и делался.
Лечится это не хитростью, а честным перевыпуском: вся линия redb.Route ушла одним номером, следом Tsak и Identity, чтобы их пины тоже переехали. Никакая комбинация 3.6.0 не соберёт битую пару.
Camel-паритет: ещё четыре вещи, которых не хватало
Message History — след, который обмен оставил в маршруте: каждый узел с временем, идентификатором и меткой; при исчерпании ретраев след выгружается в лог, и видно, на каком шаге всё встало.
XSLT — .Xslt(...), .XsltContent(...) и компонент xslt:. Нужен ровно там, где нужен: чужие форматы, которые проще преобразовать таблицей стилей, чем кодом.
Routing Slip — .RoutingSlip(...): список эндпоинтов вычисляется во время выполнения, обмен идёт по нему последовательно.
Плейсхолдеры в URI эндпоинтов — {{key}} и {{key:default}}, как в Apache Camel. Один и тот же маршрут работает в трёх окружениях без ветвлений в коде.
IBM MQ: от полусекунды до единиц миллисекунд
Не в этом релизе, а в предыдущем, но заслуживает упоминания, потому что цифра говорит сама.
Штатный консьюмер опрашивал очередь блокирующим MQGET-WAIT на managed-клиенте IBM.WMQ, у которого внутренний тик ~500 мс и он не зависит от waitInterval. На простаивающей очереди сообщение доезжало через 250–500 мс после появления. Публичного async-API у managed-клиента нет ни в одной версии 9.4.x.
Приём переведён на событийную модель через MessageListener XMS — задержка упала до единиц миллисекунд. Режим включается явно; поллинг остался режимом по умолчанию.
Identity: уникальность e-mail держится индексом, а не проверкой
Регистрация работала так: посмотреть, есть ли пользователь с таким адресом → если есть, отдать 409 → если нет, создать. Между проверкой и вставкой две одновременные регистрации обе видят «свободно» и обе пишут один адрес. То есть RequireUniqueEmail под конкурентностью не держался. У логина такой проблемы не было никогда — там реляционный UNIQUE.
Теперь гарантию даёт база: частичный уникальный индекс UX_users_email на users(email) с условием WHERE _email IS NOT NULL. Частичный не для красоты — пустых адресов много, а у SQL Server обычный UNIQUE допускает лишь один NULL. Адрес нормализуется (trim + lower-invariant) перед проверкой и вставкой, иначе A@x.com и a@x.com были бы для индекса разными людьми. Нарушение индекса отдаётся как 409 duplicate, а не как 500.
Проверка перед вставкой осталась — но теперь она то, чем и должна быть: быстрый детерминированный ранний отказ, а не сама гарантия.
Заодно из горячего пути регистрации убраны отладочные леса: секундомер с двумя десятками вызовов-пустышек и VERIFY-блок, который после каждого создания слал два лишних запроса в базу, чтобы накормить эти пустышки. Минус два обращения к БД на регистрацию.
Tsak: dead-letter, который на PostgreSQL не работал
Очередь недоставленных сообщений писалась и читалась через Sql-компонент, и на PostgreSQL ломалась дважды.
Захват не сохранялся вообще: флаг replayable биндился как int 1/0 в колонку BOOLEAN, а в Postgres нет неявного приведения integer → boolean — весь INSERT падал с 42804. И собственный catch захвата это исключение проглатывал, так что недоставленные сообщения просто исчезали.
Второе: временные метки биндились ISO-строками против нативных timestamptz. Неявного text → timestamptz в операторе сравнения тоже нет, поэтому DELETE ... WHERE occurred_at < @cutoff падал с 42883 — то есть суточная джоба ретенции и любой запрос дашборда с фильтром по дате.
Оба лечения одинаковые по духу: биндить типами, а не текстом. bool → boolean/bit/0-1, DateTimeOffset → нативный timestamp.
Мелочи, которые заметны
SumRedbAsync и AverageRedbAsync кидали InvalidOperationException на пустой выборке. SUM/AVG без строк — это NULL в SQL, а результат читался через JsonElement.GetDecimal, которому нужен Number. Путь стал достижимым только после того, как в 3.5.0 агрегаты по базовым полям начали учитывать фильтр: до этого WhereRedb(...) терялся, агрегат всегда шёл по всей схеме и NULL не получался никогда. Один фикс открыл дорогу другому багу — обычная история, если фильтр раньше молча игнорировался.
Fluent DSL и парсер URI разошлись в кодировании: строитель кодировал значения не так, как парсер их потом разбирал, и значения с пробелами не переживали круговой обход. Теперь обе стороны используют Uri.EscapeDataString.
Как поставить
dotnet add package redb.Core dotnet add package redb.Postgres # или redb.MSSql / redb.SQLite dotnet add package redb.Postgres.Pro # Pro — бесплатно, без ключа dotnet add package redb.Route dotnet add package redb.Route.As2
Pro остаётся проприетарным, но бесплатным и без лицензионного ключа на всей линии 3.x. Всё собрано под .NET 9.
Исходники — github.com/redbase-app, про хранилище — redb.ru.
Что дальше
Продолжаем разбирать входящее. Журнал отзывов ведётся в репозитории, и туда попадает каждый внешний сигнал — валидный, спорный и отклонённый, с оценкой и ссылкой на то, куда он зафиксирован. Если вы нашли что-то похожее на два дефекта выше — пишите: разбор с воспроизведением стоит дороже любого пожелания.
Другие мои статьи — redb.ru/articles, ещё — на Хабре.
If this was useful — a ⭐ on GitHub helps others find it.

