DEV Community

rinat kozin
rinat kozin

Posted on • Originally published at redbase.app

redb 3.6.0: a bug report that turned out to live in six providers — plus AS2/EDI and a shared port

redb ecosystem

A user's report exposed a cross-conversation leak in the core query provider — 6 of 6. Also in 3.6.0: AS2/EDI, a shared Kestrel, Camel parity.

For a year this stack grew on our own workload: we built what we needed and proved it in our own production. Since spring other people have been using it — and the mail changed character. Instead of "do you support X" we now get analyses: a reproduction, line numbers in our sources, and the workaround the reporter already wrote on their side while waiting for us.

That is the most valuable thing that can happen to a project. Thank you to everyone who wrote in rather than quietly switching to another library. One such report is the backbone of half this release — and the defect it exposed was not where it was found.

So: redb 3.6.0 — the core, the redb.Route integration engine, the redb.Tsak runtime and the redb.Identity OIDC server, all on one number, 63 packages.

The defect was two layers below where it surfaced

A developer building a chat product on redb.Route.Llm wrote: "a new person's first turn reaches the model carrying somebody else's history, and their first message gets attached into a stranger's tree."

That reads like an LLM-connector bug — conversation histories are stored as trees, something must be wrong in how a branch is loaded. We went to look, and the connector had nothing to do with it.

In the core query provider, WhereLeaves() replaced the root CTE instead of acting as a predicate on top of it. So this:

// Intent: the leaves of THIS tree
var leaves = await redb.TreeQuery<MessageProps>(root)
    .WhereLeaves()
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

actually meant "the freshest leaves of the scheme across the entire database". No root filter survived into the query at all.

The arithmetic from there is simple. In a multi-user deployment "the freshest leaf of the scheme" is the transcript of whoever wrote last, globally. Not only on the first turn — on every turn. Every conversation collapses into a single feed. Worse, the next message is attached under a parent in someone else's tree, so the data is not merely read wrong, it is written wrong.

The defect was cross-provider: six out of six — Postgres, MSSql, SQLite, each in Free and Pro. The fix is the same everywhere: tree_leaves / tree_roots are now seeded from the root instead of displacing it.

SQLite deserves its own note. The Free tier executes queries inside a native extension, so the fix lives in redb_pvt.c, not only in C#. A package carrying the previous binaries would have shipped the fix on the managed side and left the leak in Free — silently, with nothing in the log. The extension was therefore rebuilt for all three RIDs (win-x64, linux-x64, linux-arm64), and that was verified by hashing against the previous release rather than by file dates: the rebuilt Linux binaries came out byte-identical in size to the old ones, so timestamps proved nothing.

The lesson worth taking even if you never touch redb: a filter that "didn't apply" and a filter that "replaced the search space" look identical in code and differ catastrophically on data. The second one raises no error. It returns a plausible answer to a different question.

The second defect in the same report: a tool with no context

The reporter's words: "the tool receives an exchange with no request context — and the only simple way to give it a user id or tenant is to ask the model to pass it as an argument, which opens a prompt-injection escalation."

That is a precise security statement. If a tool learns on whose behalf it was invoked from an argument the model produced, then talking the model into forging that argument is the whole attack.

Before: the engine copied a hard-coded allowlist of three headers into the tool exchange and nothing else. Now:

  • tool routes run as a child of the agent exchange, not as a naked one — context, scope and tracing are inherited;
  • the run's principal and audit tags reach tools — and as the values resolved for the run, not as raw headers. The difference matters: ?user=${header.X-User-Id} and ?audit= are resolved by the engine, and a plain header copy would have missed them entirely;
  • .PropagateToolHeaders(...) was added — an explicit list of extra headers to forward, with a trailing * acting as a prefix match. The list is empty by default: propagation is a decision, not a convenience.
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");
Enter fullscreen mode Exit fullscreen mode

That method is why this release is a minor rather than a patch: new public API does not ship under a patch number.

AS2 and EDI, inside the route

Large retail, 3PL operators, banks running host-to-host, automotive — for twenty years they have exchanged purchase orders, invoices and shipping notices over AS2: signed and encrypted S/MIME over HTTP, with a signed receipt coming back. In .NET that has meant a commercial gateway or a Java server sitting beside your integration.

AS2 is now an ordinary redb.Route connector, schemes as2: and as2s:. Both directions, synchronous and asynchronous MDN, signed receipts, the full algorithm matrix (sha-1/256/384/512 × aes-128/192/256-cbc, 3des, optional RFC 3274 compression). Crypto is MimeKit over Bouncy Castle — the same foundation the AS2 industry interoperates on.

Interop was validated against a live OpenAS2 v4.9.0 in both directions — signed and encrypted payload, positive MDN, MIC verified.

The full walkthrough — routes, partner profiles, and how this differs from a gateway — is in the AS2 article.

One port for several connectors

An AS2 receiver is an HTTP server. You already have HTTP routes. The partner wants a single endpoint and TLS terminated once — so both have to live on the same port.

The Kestrel multiplexer (one server per host:port) used to live inside redb.Route.Http. For AS2 to use it, AS2 would have to depend on a general-purpose transport connector — dragging the whole HTTP stack into an application for the sake of one class, and inverting a sensible dependency direction.

So SharedHttpServerManager moved into its own package, redb.Route.Http.Hosting. Both connectors depend on it and not on each other; registration is idempotent and every connector resolves the same singleton. The types kept the redb.Route.Http namespace, so existing code needs no edits.

The honest part of this story is worth telling, because it generalises. The extraction landed after redb.Route.Http had already been published to nuget.org, and a published version cannot be replaced. Meanwhile redb.Route.As2 depends on Hosting but not on Http — so NuGet had no reason to ever lift Http to the fixed build, and anyone combining the old Http with As2 would quietly get two independent Kestrel managers: exactly the problem the extraction exists to prevent.

There is no clever fix for that, only an honest republish: the whole redb.Route line moved as one number, with Tsak and Identity following so their pins moved too. No combination of 3.6.0 packages can assemble the broken pair.

Camel parity: four more things

Message History — the trail an exchange leaves through a route: every node with its timing, id and label; when retries are exhausted the trail is dumped to the log, so the failing step is visible.

XSLT.Xslt(...), .XsltContent(...) and the xslt: component. Useful exactly where it is useful: other people's formats, easier to transform with a stylesheet than with code.

Routing Slip.RoutingSlip(...): the endpoint list is computed at runtime and the exchange is piped through it in order.

Property placeholders in endpoint URIs{{key}} and {{key:default}}, as in Apache Camel. One route text works across three environments with no branching in code.

IBM MQ: from half a second to single-digit milliseconds

From the previous release rather than this one, but the number speaks for itself.

The default consumer polled with a blocking MQGET-WAIT on the IBM.WMQ managed client, which carries an internal ~500 ms tick that is independent of waitInterval. On an idle queue a message arrived 250–500 ms after it was actually there. The managed client exposes no public async-consume API in any 9.4.x version.

Receive was moved to an event-driven model via the XMS MessageListener, cutting latency to single-digit milliseconds. It is opt-in; polling remains the default.

Identity: e-mail uniqueness held by an index, not by a check

Registration used to work like this: look up whether a user with this address exists → if so, return 409 → if not, create. Between the check and the insert, two concurrent registrations both see "free" and both write the same address. So RequireUniqueEmail did not hold under concurrency. Login never had this problem — it is backed by a relational UNIQUE.

The guarantee is now in the database: a partial unique index UX_users_email on _users(_email) with WHERE _email IS NOT NULL. Partial is not cosmetic — null e-mails are common, and SQL Server's plain UNIQUE permits only one NULL. The address is normalised (trim + lower-invariant) before both the check and the insert, otherwise the index would treat A@x.com and a@x.com as two different people. An index violation surfaces as 409 duplicate, not as a 500.

The pre-insert check stayed — but it is now what it should always have been: a fast, deterministic early error rather than the guarantee itself.

The registration hot path also lost its debug scaffolding: a stopwatch with two dozen no-op marker calls, and a VERIFY block that fired two extra queries after every create just to feed those no-ops. Two fewer database round-trips per registration.

Tsak: a dead-letter store that did not work on PostgreSQL

The dead-letter queue is written and read through the Sql component, and on PostgreSQL it broke twice.

Capture never persisted at all: the replayable flag was bound as an int 1/0 into a BOOLEAN column, and Postgres has no implicit integer → boolean cast, so the whole INSERT threw 42804. Capture's own catch swallowed it, so dead letters were dropped on the floor.

Second: timestamps were bound as ISO-8601 strings against native timestamptz columns. There is no implicit text → timestamptz cast in a comparison operator either, so DELETE ... WHERE occurred_at < @cutoff threw 42883 — taking out the daily retention job and every date-filtered dashboard query.

Both fixes are the same idea: bind types, not text. boolboolean/bit/0-1, DateTimeOffset → a native timestamp.

Smaller things you would notice

SumRedbAsync and AverageRedbAsync threw InvalidOperationException on an empty selection. A SUM/AVG over no rows is NULL in SQL, and the result was read through JsonElement.GetDecimal, which requires a Number. This path only became reachable after 3.5.0 made base-field aggregations honour the filter: before that WhereRedb(...) was dropped, the aggregate always spanned the whole scheme, and NULL never happened. One fix opened the road to another bug — the usual consequence of a filter that used to be silently ignored.

The fluent DSL and the URI parser disagreed on encoding: the builder encoded values differently from how the parser read them back, so values containing spaces did not survive a round trip. Both sides now use Uri.EscapeDataString.

Getting it

dotnet add package redb.Core
dotnet add package redb.Postgres        # or redb.MSSql / redb.SQLite
dotnet add package redb.Postgres.Pro    # Pro — free, no key

dotnet add package redb.Route
dotnet add package redb.Route.As2
Enter fullscreen mode Exit fullscreen mode

Pro stays proprietary but free and key-less across the whole 3.x line. Everything targets .NET 9.

Sources: github.com/redbase-app. About the store itself: redbase.app.

What is next

We keep working through what comes in. The feedback journal lives in the repository, and every external signal lands there — valid, arguable and rejected alike, with our assessment and a link to wherever it was filed. If you have hit something resembling the two defects above, write in: an analysis with a reproduction is worth more than any wish list.

More of my writing: redbase.app/articles, and on dev.to.

If this was useful — a ⭐ on GitHub helps others find it.

Top comments (0)