redb 3.7: our own gRPC wire, cutting props before the aggregate, and a release withdrawn a day later

Three version numbers went out in three days: 3.7.0, 3.7.1 and 3.7.2. The first one is withdrawn, the live number is 3.7.2. Below is what shipped in that line, in dependency order: the integration engine redb.Route, the store redb.Core, the runtime redb.Tsak and the OpenID server redb.Identity. All four move on one number, 66 packages.

Let me start with why there are three numbers.

Why 3.7.0 was withdrawn

3.7.0 was built on .NET 9 and carries known high-severity vulnerabilities in its dependencies. Every 3.7.0 package is unlisted on nuget.org and the v3.7.0 tags were deleted from the public mirrors. An unlisted version still installs by exact number, but there is no reason to: 3.7.1 replaces it completely and changes no public API.

How those vulnerabilities were found is the more interesting part, because the mechanism is universal.

The applications and artifacts were built on net9 while redb.Core and redb.Route had long multi-targeted net8.0;net9.0;net10.0. The gap surfaced on 3.7.0: images and archives shipped as net9. Changing the TFM forced a from-scratch rebuild, and on a full build the NuGet audit spoke up. On an incremental build it stays silent. That is how everything below reached publication.

What it found:

  • SSH.NET 2025.1.0 in redb.Route.Sftp, a direct dependency (GHSA-q939-rpr3-3284). Bumped to 2026.0.0; the connector's own suite passes 207 of 207 against it.
  • the same SSH.NET, version 2024.2.0, pulled transitively into the Kafka, RabbitMQ and Redis test projects. The root was Testcontainers.* 4.3.0 against 4.14.0 available, so the version was raised rather than the symptom patched in three places.
  • SQLitePCLRaw.lib.e_sqlite3 2.1.10 in redb.Export, transitively through Microsoft.Data.Sqlite 9.0.3. redb.SQLite had pinned its way out of this long ago; redb.Export sat on the older Microsoft.Data.Sqlite and slipped past that guard.
  • System.Security.Cryptography.Xml 9.0.4 in redb.Identity.DataProtection: seven advisories at once, transitively from Microsoft.AspNetCore.DataProtection 9.0.4. An XML cryptography library, inside the product that does authentication. Pinned to 9.0.18, the patched 9.x release.
  • Microsoft.Bcl.Memory 9.0.0 in redb.Identity.Http, transitively from OpenIddict.Abstractions. Pinned to 9.0.19.

The 9.x line rather than 10.x wherever the library multi-targets down to net8.0: 10.x does not install there.

The build moved to .NET 10 along the way. The redb.Tsak.* and redb.Identity.* libraries now declare net8.0;net9.0;net10.0, exactly like redb.Core and redb.Route; host applications and tests are pinned to a single net10.0, and images, archives and tags are -net10. .NET 8 and .NET 9 both reach end of support on the same day, 10 November 2026: Microsoft aligned the STS 9 date with LTS 8. .NET 10 is supported until 14 November 2028. redb.CLI now requires .NET 10, because roll-forward only moves up.

3.7.2 followed because of a regression in redb.Core, covered below. 3.7.1 stays listed: it is superseded, not withdrawn.

redb.Route: a gRPC method address is now a route

The largest thing in the release. The redb.Route.Grpc connector was rewritten.

Before, every From("grpc:host:port") built its own Kestrel. A second gRPC route on the same port could not bind and failed, so a facade covering several operations had to be one route with a Choice() inside and a selector on a private header.

Now the consumer registers its method address (/package.Service/Method) as a path route on the shared Kestrel host, the same SharedHttpServerManager that already serves Http, As2 and Soap, and speaks the gRPC wire protocol itself. That is GrpcWire: length-prefixed framing, grpc-status and grpc-message trailers, deadlines from grpc-timeout.

// One port, two methods, two routes.
From(GrpcDsl.Listen("0.0.0.0:5001").Method("/identity.v1.Identity/Token"))
    .RouteId("grpc-token")
    .To("direct-vm://identity-token");

From(GrpcDsl.Listen("0.0.0.0:5001").Method("/identity.v1.Identity/Introspect"))
    .RouteId("grpc-introspect")
    .To("direct-vm://identity-introspect");

Every operation gets its own route id, its own policies, metrics and lifecycle. The method address selects the route, not a header. A URI with no method address keeps serving the built-in RedbService/Process and ProcessStream, unchanged.

What came with it.

Real gRPC statuses. The transport-neutral status.code that every controller dispatcher writes is mapped onto a gRPC status: 401 to Unauthenticated, 403 to PermissionDenied, 404 to NotFound, 429 to ResourceExhausted. redbGrpc.Trailer.* headers become response trailers. A non-OK status is delivered trailers-only, because clients discard the payload of a failed call.

Typed .proto services without generated server stubs. Envelope=Auto keeps the RedbMessage wrapper for the built-in address and passes raw protobuf bytes for any other, so a client generated from a real .proto calls a redb route directly.

Streaming in both directions. An IAsyncEnumerable reply body is written one frame per yield. On the client side .Streaming() makes the call server-streaming and puts an IAsyncEnumerable into Out.Body, so a gRPC stream flows straight into a streaming consumer (the HTTP one turns it into SSE or chunked output) without being buffered in between. Parity with camel-grpc's producerStrategy=STREAMING.

mTLS, health, gzip. .ClientCertificates(mode, thumbprints…) requires and pins client certificates, surfacing redbGrpc.ClientCert*. .Health() serves grpc.health.v1.Health/Check for Kubernetes, Consul and Envoy probes. Compressed requests are accepted and inflated, and the size limit is re-checked after inflation, so a small frame cannot expand into an arbitrarily large buffer.

camel-grpc parity on the URI: grpc://host:port/my.Service?method=Call works alongside the full-address spelling, plus maxMessageSize and negotiationType=PLAINTEXT|TLS. redb.Route.Controllers gained [GrpcMethod("Name")], which pins the name gRPC callers dispatch on so renaming a C# method is not a breaking change.

Grpc.AspNetCore is gone from the dependencies: there is no server stack any more. What remains is the message layer (Google.Protobuf plus Grpc.Tools as a build-only dependency) and the client channel Grpc.Net.Client. The generated redb_service.proto now emits GrpcServices="Client", because the server side is ours.

What to read before upgrading

Two behavioural changes, both in redb.Route.Grpc.

The producer throws on a failed call (ThrowOnError, default true). It used to record the RpcException on the exchange and return, but nothing in the pipeline reads that field, so .OnException(...), retry and dead-letter never saw the failure and the route carried on with an empty Out. It now behaves like the HTTP and SOAP producers. Set throwOnError=false for the old behaviour.

Errors reach clients as gRPC statuses instead of OK plus an error document. A caller that ignored the status and parsed the body will now see an RpcException. Set suppressStatusMapping=true to keep answering OK.

Three smaller items of the same kind: conflicting listener settings on one port now throw instead of being silently discarded (which used to put a gRPC route, HTTP/2 only, on an HTTP/1.1 listener and fail every call with an unreadable framing error); the consumer opens its own grpc receive span; and GrpcEndpoint.BuildProducerAddress() composes from host and port, so a URI without an explicit port (grpc:myhost) resolves to http://myhost:50051.

Verified by a foreign stack

Since the wire protocol is ours now, correctness is not checked by our own tests. The interop suite drives Node.js @grpc/grpc-js in a container, both directions and cross-process: a foreign server parses our frames, a foreign client accepts our replies, trailers, a PERMISSION_DENIED status, a server stream, a gzipped request and a real mTLS handshake with a pinned client certificate. The contract is a typed .proto, so the same tests prove a generated client can call a redb route with no server stubs on our side. The suite is gated on the container (--filter Category=Interop), mirroring the SOAP and AS2 fixtures.

Four defects from a critical review of the connector

All four share one shape: untrusted or upstream-supplied input reaching code that sits outside the handler's own try block, so the failure escaped the route's error contract entirely.

A crafted grpc-timeout could kill the request at the host. The microsecond arm multiplied a caller-supplied long by 10 unchecked: 1000000000000000000u wrapped to a negative tick count and CancelAfter threw ArgumentOutOfRangeException before the try block. Worse, the catch only handled OverflowException, while TimeSpan.FromHours and friends raise ArgumentOutOfRangeException, so 9223372036854775807H escaped the method entirely. Both are caught now, the multiply is checked, and an unreadable deadline means what the doc always claimed: no deadline enforced, call proceeds.

A header name the wire cannot express killed the call. The producer copied every exchange header into gRPC metadata, whose key alphabet is far narrower: Metadata.Add throws on spaces, non-ASCII, and on any -bin suffix, and trace-bin is a perfectly legal HTTP header name. That loop ran before the try, so one odd header from an upstream HTTP consumer took the whole call down with an ArgumentException carrying no status. Unrepresentable keys are now dropped and logged; the rest of the headers still travel.

A malformed envelope was reported as our fault: garbage in the caller-supplied bytes fell through the catch-all as INTERNAL, telling the caller "server problem, retry" about input only they can fix. Now INVALID_ARGUMENT, naming what was expected.

A server stream that broke mid-flight was invisible. Streaming failures surface while the consumer enumerates, long after Process returned, so .OnException, retry and dead-letter cannot see them: that is inherent to lazy streaming. But nothing recorded them either, so a stream that broke every time looked like a stream that ended early. The break is now logged and counted on the endpoint, then rethrown so the reader still learns the stream did not finish.

redb.Route: SOAP, Control Bus and Claim Check

SOAP arrived as its own connector, redb.Route.Soap, schemes soap and soaps, oriented to camel-cxf: 1.1 and 1.2 envelopes, two header planes (transport HTTP and the <soap:Header> block), WS-Security with UsernameToken, body signing and body encryption, three data formats (Payload, Message, Pojo), MTOM/XOP attachments and WSDL publishing on ?wsdl. The crypto is validated end to end by an independent stack, Node.js over OpenSSL. Full write-up in the SOAP connector article.

Control Bus is route management by message: start, stop, suspend, resume, restart, status, stats, fail, addressed by routeId, where current targets the sender. Plus one extension beyond Camel: controlbus:notify as a consumer of lifecycle events, so route and context events flow into an ordinary route and are handled with the full EIP pipeline.

From("kafka://ingest")
    .Choice().When(Overloaded).ControlBus(ControlBusAction.Suspend, "current", async: true).End()
    .To("direct://process");

From("controlbus:notify").Filter(IsError).To("telegram://ops");

Stopping the current route is auto-deferred through async dispatch, so a route can safely stop itself without deadlocking on its own in-flight exchange. Full option list in the Control Bus article.

Claim Check is instructive on its own. The processor, five operations, the headers and two repositories were all written and covered by tests, but nothing constructed ClaimCheckDefinition, so the pattern could not be used from a route at all. Now it can:

.ClaimCheck(ClaimCheckOperation.Set, "order-42")   // body to the store, a key travels the route
.To("kafka://orders")                              // the broker carries the key, not the payload
.ClaimCheck(ClaimCheckOperation.Get, "order-42")   // key back to body, restored as its original type

Push and Pop use an exchange-scoped stack instead of a key and nest, so a body can be parked around an enrich call and restored after it. The repository is resolved at compile time, and an unknown name fails at startup naming the missing registration rather than on the first message.

redb.Route: files that vanished quietly

A critical review of the file transports produced four defects, three of which lost data without a line in the log. All three survived a fully green suite, because the tests covered options one at a time while the defects lived in their combinations.

readLock=Rename delivered empty files forever: the strategy renames the file aside to claim it, but the consumer kept reading the original path. readLock=FileLock did the same for the opposite reason: the strategy held the file open with FileShare.None, so the consumer's own second open was refused by its own lock. idempotent together with any readLock could lose a file permanently: the key was claimed before the read lock was taken, and the lock-refused path returned without releasing it. A custom idempotentKey was used as a literal, so every file got the same key and every file after the first was silently skipped.

The three share one denominator: CreateExchangeAsync caught every read error, logged a warning and substituted Array.Empty<byte>(), after which post-processing happily deleted or archived the file. A read failure is now a processing failure: the key is released, moveFailed applies, the file stays where it is, and the rest of the poll batch continues.

Separately, the idempotent repository folded case, so Order.csv shadowed order.csv. On every SFTP and FTP server and every non-Windows file system those are two different files, and the second was skipped as a duplicate it never was. Full write-up with reproductions in the file connector article.

redb.Route: security

The file producer would write anywhere the incoming message told it to. The target file name normally arrives from the redbFile.Name header, i.e. from whatever produced the message: an uploaded file name, a partner's file name, a field of a payload. The local producer never validated it, ValidatePath was a no-op in the shared base and only the remote transports overrode it. Two ways out of the endpoint directory: a relative ../escaped.txt, and an absolute path, which Path.Combine silently honours by discarding the base entirely. The local producer now jails the target the way SFTP and FTP already did, under the same option name jailStartingDirectory, default true.

The jail itself compared a bare string prefix. With a base of /upload/in, the target /upload/instructions/x starts with the base and was allowed through, into a directory the endpoint has nothing to do with. The check now compares on the directory boundary via the shared GenericFileUtils.IsWithinDirectory, and FileClaimCheckRepository.GetSafePath moved onto the same helper.

Asking a gRPC producer for TLS left it connecting in cleartext. .Ssl() sets ssl=true, but the producer builds its target address from Plaintext, a separate option that defaults to true and that nothing linked to ssl. So GrpcDsl.Call("host:443").Ssl() produced http://host:443: the obvious spelling of "use TLS" was ignored, silently, on the leg that carries credentials outward. ssl=true now implies plaintext=false, and an explicit plaintext still wins so local debugging keeps its knob.

In SOAP, a signature-wrapping bypass is closed. The anti-wrapping check resolved the protected Body with GetElementsByTagName, in document order and at any depth, while the processing path reads the Envelope's direct-child Body. An attacker could nest a genuinely signed Body inside <Header> and put an unsigned Body as the direct child: the signature validated over the original while the route consumed the attacker's content with redbSoap.signatureValid=true. Verification now resolves the same direct-child Body the route uses and rejects an envelope with more than one Body. In the same connector, XML-Encryption stopped leaking extra Body children in cleartext: only the first child was encrypted, so a document/literal body with several elements sent the rest unencrypted.

In AS2, the receiver now enforces the partnership's requirements. signatureValid was computed and never acted on: an unsigned message, or one whose signature failed, was delivered to the route and answered with a positive MDN. The crypto was correct all along; only the result was ignored. Symmetrically, MdnParser defaulted SignatureValid to true and lowered it only for a signed MDN, so a stripped-signature or fabricated multipart/report read as a valid signed receipt. And SSRF through Receipt-Delivery-Option is blocked: the async-MDN receipt URL was POSTed to verbatim and could point at link-local metadata such as 169.254.169.254.

Common to HTTP and gRPC: a caller can no longer forge transport headers. HttpConsumer set redbHttp.RemoteAddress from the connection and then copied the request headers over it, so a client sending a header literally named that (a valid HTTP token) replaced the socket address. That address is the input to per-IP rate limiting, brute-force lockout and audit records. Inbound headers carrying a transport-reserved prefix (redbHttp., redbGrpc., redbSoap., redbSignalR., redbMail., redbAs2.) are now dropped; the gRPC consumer applies the same rule to metadata and envelope headers, with allowClientReservedHeaders=true as an explicit, logged opt-out.

redb.Route: two reports from GitHub

An unhandled exception in a SEDA consumer killed the worker (issue #6). The worker loop caught only cancellation and channel-closed, so a single failing exchange (say a DB unique violation with no .OnException) terminated the loop permanently and silently: the producer kept enqueueing, the route stopped consuming, and the exception surfaced only at shutdown. ProcessWithTracking now logs an unhandled exchange failure and drops it, so the consumer keeps draining, which is Apache Camel's DefaultErrorHandler plus SedaConsumer behaviour. The fix covers every ProcessWithTracking consumer: seda, direct-vm, timer, and the S3, Elasticsearch, Firebase and LDAP pollers.

AddRouteBuilder<T>() and AddComponent<T>() failed host startup (issue #5): the builder was registered only under its base type while the configurator resolves the concrete type, so startup threw InvalidOperationException: No service for type … has been registered. The documented onboarding path did not work.

redb.Core(.Pro): a property changing type could silently lose its values

The nastiest defect in the release, on all providers, Free and Pro.

Scheme synchronisation migrates a structure's stored values when the CLR property's type changes, and only then switches _structures._id_type. The migration was called with the old type's numeric id passed into a lookup keyed by name. The lookup matched nothing, fell back to the literal "unknown", and every provider answered "unknown source type". That answer was discarded, the type was switched anyway, and the values stayed in the old column, read as null, and were physically deleted by the next save under the default DeleteInsert strategy.

Four independent layers had to line up for this to stay quiet: an id used as a name, a ?? "unknown" swallowing the miss, providers reporting failure as data rather than raising, and the caller throwing the result away. Each is now closed: the type is resolved by id, a missing type raises, the result is inspected, and _id_type changes only after a migration that actually completed.

A migration that cannot complete now raises RedbTypeMigrationException and stops synchronisation. That is deliberate: the alternative outcomes are a structure whose values read as missing, or a CLR class and a structure that quietly disagree. The exception carries the scheme, the property, both type names, how many values moved and how many did not, and the SQL to migrate by hand. A structure with no stored values is unaffected: there is nothing to strand, so those type changes still pass.

Two relatives sat next to it. SQLite refused every cross-column migration wholesale, even though bool to int is a plain copy: _Boolean and _Long are both INTEGER there. SQLite now implements the same matrix PostgreSQL does for scalars, and text sources are guarded per row rather than cast blindly, because SQLite has affinity, not types, and CAST('abc' AS INTEGER) is 0, not an error. Reference columns (_ListItem, _Object) stay refused: an FK cannot be produced from a scalar.

The String to Boolean migration on PostgreSQL and MSSQL destroyed values it could not read: an unrecognised token was mapped to NULL through a CASE while the same statement cleared _String. The value was gone, and because success is counted as rows updated, it was reported as a success. Every neighbouring text conversion was already guarded by a predicate; this one was not, on both providers.

And the organisational consequence: migrate_structure_type lived in sql/, which lands only in redb_init.sql, applied when the tables are absent, i.e. to fresh databases only. Any correction to that function was therefore unreachable for every database already in use. It moved into the versioned module, so the version check redeploys it on the next start like the rest of the module.

redb.Core(.Pro): case-insensitive search only worked for Latin

Contains(needle, OrdinalIgnoreCase) found HELLO but not ПРИВЕТ. The same held for Greek, Hungarian, Polish, Czech and French.

Case folding comes from the database's own rules. On SQLite that is ASCII-only unconditionally: LIKE, lower(), upper() and even COLLATE NOCASE. On PostgreSQL it happens whenever the database was created with LC_CTYPE=C. On SQL Server it never happens, since its default collation already folds every script.

The new RedbServiceConfiguration.StringCollation setting fixes every script whose case mapping is one character to one character, in one place and with no per-language work. It covers the whole family together, ContainsIgnoreCase, StartsWithIgnoreCase, EndsWithIgnoreCase, ToLower, ToUpper and the case-insensitive regex, so a search can never disagree with a comparison.

The implementation differs per provider, because the providers differ in kind. PostgreSQL attaches COLLATE to the folded operand: in Pro that happens in C#, in Free through the new pvt_fold_case() reading a redb.string_collation GUC, which needed no change to any function signature. SQLite has nothing to attach a collation to, so it replaces the built-in like, lower and upper with Unicode-aware ones on the connection, the same technique SQLite's own ICU extension uses, and with no native rebuild. SQL Server needs nothing.

Two caveats are documented rather than hidden. On PostgreSQL a collated operand cannot use an index built with the database's collation, so a trigram search degrades to a full scan until a matching expression index is created (the DDL is in COLLATION.md; redb does not create it for you). And diacritics, German ß versus SS and the Turkish dotted İ are not case folding and are not fixed; what the last two do differs per provider, which the test suite now pins per provider rather than assuming.

The same work exposed that Pro on PostgreSQL discarded the configured dialect. ProRedbService resolved both ISqlDialect and ISqlDialectPro from the container but handed only the base one to ProQueryableProvider; ProQueryProvider then narrowed it with as ProPostgreSqlDialect, a base instance failed that cast, and the fallback silently constructed new ProPostgreSqlDialect() with no configuration at all. It was latent for as long as the dialect carried no settings. StringCollation was the first, and it vanished on every Pro query while working correctly on Free.

redb.Core(.Pro): time

A DateTime in redb carries no time zone: 14:00 written is 14:00 read, on any host. Object materialization honoured that; the analytics path did not. JsonValueConverter parsed a zoned ISO string with the default DateTimeStyles, which converts it into the caller's local zone, so MinRedbAsync, GroupBy, Window and scalar projections answered with a different value than the object did for the same stored field.

Separately, DateOnly never round-tripped on any provider. It was seeded with _db_type = 'DateTime', a value no get_object_json branch knows about, so the column was written correctly and dropped on the way out, and every DateOnly property materialized as 0001-01-01. Retyped to DateTimeOffset, which routes it through the branch that already exists everywhere: no SQL function changed, no module version bump, no native SQLite rebuild. Migration scripts for existing databases are in the repository.

Fixed alongside: DateOnly, TimeOnly and TimeSpan went into _values._String through the current culture's short pattern and were parsed back the same way, so a row written under ru-RU stopped loading under en-US. The TimeSpan JSON form dropped the day component and the sign (3.02:00:00 came back as 02:00:00). And the filter needed the same invariant spelling in both the Free and Pro paths, or a saved row was unfindable by its own value. All temporal text now goes through a single RedbTemporalFormat. The contract, the precision table and the boundaries left open are in DATETIME.md.

redb.Core.Pro: cutting props before the aggregate

A filter over props compiled into a condition sitting above GROUP BY, so by the time it was evaluated the engine had already read and folded every value of every object in the scheme. Query cost did not depend on selectivity at all: hunting for one rare order number cost exactly as much as a search that found nothing.

The prefilter narrows the object set before the aggregate runs, and it is built as a superset: it may let extra objects through, it may never lose one. It is opt-in through EnablePvtPrefilter, off by default. A date range gains a hundredfold on PostgreSQL, sixteenfold on SQLite, and a full result set is 5.3x faster on SQL Server. Detailed measurements, the limits of what gets a prefilter and the two planner guards are in the dedicated article.

In 3.7.2 the planner gained three shapes it used to refuse for no good reason: a disjunction nested inside a conjunction (the ordinary shape of a search box scoped to a subtree), several branches over one structure, and ListItem.Id. The last one lives in the row's own _listitem column, so Status.Id == 42 is literally v._listitem = 42, with no join needed.

Diagnostics came with it: an applied plan explains itself in ToSqlStringAsync, and a refusal names the guard that stopped it.

-- PVT prefilter: Row form, 2 branch(es) over 2 structure(s), score 70
--   branch: structure 1000032, column _string, Contains, score 70
--   branch: structure 1000034, column _string, Contains, score 70
-- PVT prefilter: not applied, reason PivotNotCovered
--   detail: pivot column(s) Age (structure 1000030) have no branch

The comments are built by the SQL preview and by nothing else, and they must never reach the executed statement: a comment that varies per query is a distinct plan-cache key in both PostgreSQL and SQL Server, which would trade a diagnostic for a cache that never hits.

redb.Core 3.7.2: array.Contains stopped parsing on .NET 10

The regression 3.7.2 exists for. A filter as ordinary as .Where(x => x.Tags.Contains("urgent")) threw NotSupportedException whenever Tags was declared T[]?, which is what Nullable enable gives you for every optional array.

C# resolves array.Contains(x) to the ReadOnlySpan overload, and the parser already unwrapped that conversion. But on .NET 10 with nullable annotations the compiler wraps the collection twice: op_Implicit around a Convert around the member access. Peeling only the outer layer left a Convert, which is not a MemberExpression, so both branches of the translation missed and the method fell through to its final throw. Conversions are now stripped in a loop from both operands.

Covered by the prefilter equivalence tests on all three providers, and verified separately against six shapes of Contains, including both IN forms over a constant collection. Full run on .NET 10.0.8: 1942 of 1944, two deliberate skips, run twice, once with the prefilter on and once off, since the prefilter is a superset that must never change a result. Six provider collections (Postgres, MsSql, Sqlite, each Free and Pro) plus the unit tests: 1920 of 1920 in both modes.

What else is in redb.Core

A covering index on _objects(_id_parent) carrying _hash now exists on PostgreSQL and SQLite too (MSSQL had one). Their nearest index stopped at (_id_parent, _id_scheme, _id) and did not carry _hash, so a tree walk that reads the object hash, which is what the transparent cache does, left the index for the heap on every row. PostgreSQL 18.1 now plans such a query as Index Only Scan, SQLite 3.46.1 as SEARCH … USING COVERING INDEX. New databases pick this up from the schema script; existing ones do not, since the initialisation script is applied only when the tables are absent.

The SQLite string value index lost its length guard. IX__values__String_not_null carried AND length(_String) < 2000 alongside IS NOT NULL, but that guard belongs to PostgreSQL, where a btree key over roughly 2700 bytes overflows the page. SQLite has no such limit, and the effect there was the opposite of the intent: SQLite does not prove implications between a query and a partial index predicate, it looks for a matching term, and no query states length(_String) < 2000. The index was simply unusable. CREATE INDEX IF NOT EXISTS does not replace an existing index, so on databases created earlier it has to be recreated once by hand.

The pivot scan no longer re-checks the scheme. Every PVT CTE carried AND v._id_object IN (SELECT _id FROM _objects WHERE _id_scheme = @p0), although _values rows are selected by _id_structure, a structure belongs to exactly one scheme, and the outer SELECT re-applies the check anyway. Verified by comparing sorted id sets before and after on all three engines, flat and tree: identical everywhere. On SQLite the flat form also ran 28% faster.

Three reports from GitHub landed here as well. ChangePasswordAsync threw UnauthorizedAccessException on a wrong current password instead of returning false, although the method is Task<bool> documented as "true if changed", so a change-password form built against the contract 500'd on the most common user error. On SQLite any property type change crashed InitializeAsync with no such table: migrate_structure_type. And documentation fixes: the EnablePropsCache summary claimed the cache works only with lazy loading enabled, which the code requires nowhere.

ExpressionSqlCache and CompiledQuery were removed from the public surface: an SQL-template cache that was never wired into any query path, and the record it returned. Formally that is a public API removal, which strict SemVer would put in a major, but a major would flip LicensePolicy.FreeThroughMajor and start charging for Pro, so it shipped as a minor deliberately.

redb.Tsak: closed by default

Two default flips, so a fresh Tsak is not wide open the moment its port is reachable.

The management API binds 127.0.0.1 instead of 0.0.0.0. Local runs work out of the box, and exposing the management plane on an external address is now an explicit choice. When the host is bound off-loopback with Tsak:Auth:Enabled=false, a loud warning goes into the log: that port grants full unauthenticated admin, meaning stop and remove contexts, upload modules, issue keys, dump config.

Roleless API keys are now denied instead of being treated as admin. Compatibility comes back through the RolelessKeysAreAdmin switch, but only as a migration bridge until keys are re-issued with explicit roles.

The dashboard got a real server-side session. It used to authenticate only inside the Blazor circuit, with an in-memory flag per circuit. Anything reachable outside a circuit was ungated, and the headline item there was the BFF log-download proxy: a plain HTTP endpoint that streamed worker log files (connection strings, tokens, PII) using the server's admin key to anyone who could reach the port. And because the circuit flag was not a real security principal, dashboard authorization was cosmetic: <AuthorizedView> only hid markup.

It is now a proper BFF: ASP.NET Core cookie authentication (tsak.auth, HttpOnly, SameSite=Strict), login and logout as real endpoints, routed pages behind AuthorizeRouteView, the role read from the signed principal, and the download proxy behind RequireAuthorization plus a path-traversal guard on the filename. The dashboard password was compared against a plaintext config value with !=, so it gave itself away through response timing, and there was no rate limiting at all. A BCrypt hash in Tsak:Web:AdminPasswordHash is now preferred, plaintext comparison is constant-time, and a shared LoginThrottle locks a login out after N failures within a window.

Downloading a whole log file was raised from Operator to Admin: a log can carry raw endpoint URIs with passwords and payload fragments. The live tail GET /api/logs stays Operator, the same data but the interactive path operators need. The root cause, that the logging layer in redb.Route writes URIs and payloads unredacted, is outside Tsak and is tracked separately.

The effective-config endpoint stopped leaking. GET /api/system/config masked Password= and Pwd= only inside values under the ConnectionStrings: prefix, plus values whose leaf key name looked sensitive. Several secret-shaped values slipped through: a …Webhook:Url (the URL is itself a bearer credential), a …Headers:Authorization token, a broker …Endpoint:Uri of the form amqp://user:pass@host, and any connection string stored outside the ConnectionStrings: prefix. Markers are now matched against the whole key path, Password= is scrubbed inside any value, and URI userinfo is masked everywhere while host and port stay visible.

Two more hygiene items. The API-key brute-force throttle only gated paths starting with /api/auth/, while the key check runs on every non-exempt request, so key guessing simply used any other endpoint. It is replaced with a failed-attempt lockout that counts key-auth failures across the whole surface and is proxy-aware: it honours the right-most X-Forwarded-For hop, the address the trusted proxy itself appended and the one a client cannot forge, and only when TrustProxyHeaders=true. And chart.js is no longer loaded from a public CDN with no integrity checking; it is self-hosted.

redb.Tsak: the cluster

Two deterministic defects that made the coordinator lose routes on planned drains and double-run them on failover.

ReleaseLockAsync was a guaranteed no-op on cordon-drain: ClusteredRoutePolicy cleared _isLeader before the call, and inside sat if (!_isLeader) return;. So a cordoned node stopped its consumer but held the lock until the TTL, and the route ran on zero nodes for up to the TTL on every drain. A watch-loop exception gave the mirror image: the node kept consuming without renewing, so after the TTL the route ran on two nodes at once.

Self-renew bypassed epoch fencing. The "owned by us, renew" branch renewed unconditionally on the initial out-of-transaction read with a last-writer-wins save, so a node whose expired lock had just been taken over with a higher epoch could clobber that takeover with its old epoch and report Acquired=true. Self-renew now runs through an atomic operation with a row lock and a re-read.

The loops were split. Heartbeat, leader-lock renewal, dead-node detection, rebalance and module start/stop used to run in one sequential loop with a single delay. A module that took longer than the lease TTL to start pushed the next renewal past the TTL: the lock expired, a peer was elected, and two nodes rebalanced at once. The same stall delayed the heartbeat, so a live node could be marked dead. Following the canon for lease-based coordination (leader election in Kubernetes, lease keepalive in etcd and Consul, session ping in ZooKeeper), a fast loop on a PeriodicTimer at min(interval, TTL/3) now does only the heartbeat and the renewal, while the heavy duties live in their own loop. Voluntary step-down on the renew deadline was added too: if renewal keeps throwing, say the store is unreachable, the node steps down locally instead of holding stale leadership until the TTL lapses.

Revoked API keys are rejected within seconds instead of up to five minutes. A key was cached for the full CacheTtl and only its own RevokeKeyAsync evicted the local cache, so a key revoked on node A, or straight in the database, stayed accepted on node B for up to five minutes. The cache now re-confirms a still-cached key against the store once per RevocationCheckInterval (default 30 seconds), which bounds the cluster-wide revocation window to that interval without a store read on every request.

DLQ replay became atomic. ReplayAsync read the entry, replayed it, then unconditionally marked it replayed, so two operators (or a double-click) both read the same pending entry and ran the business exchange twice. Replay now takes a claim first with a conditional UPDATE … WHERE entry_id=@id AND status='pending': the database serializes it, and exactly one caller sees affected == 1. On replay failure the claim is released back to pending, and a crash between claim and replay leaves the entry visibly in replaying rather than silently lost.

Also: the daily audit and DLQ retention sweeps became cluster singletons through .Cluster(true), the first built-in Tsak routes to dog-food that policy.

redb.Tsak: module isolation and hot swap

TsakCoordinator subscribed to the registry's synchronous events with async lambdas, i.e. async void. Any exception after the first await, most easily a context-name collision, surfaced as an unobserved exception on a thread-pool thread and terminated the whole process. Two hot-added modules claiming the same context name took the node down instead of logging an error. Every handler is now wrapped, and a name collision is a logged skip: the offending module is skipped, the rest of the batch keeps loading, the node stays up.

The chain was then made coherently async: events are enqueued and a single background consumer awaits each handler in FIFO order, and registry persistence and the heartbeat snapshot stopped blocking through .GetAwaiter().GetResult().

Three leaks into the Default ALC. A module's private probe-path dependency was byte-loaded into the Default ALC, which pinned it in memory (that cannot unload with a collectible module) and made two modules with different versions of the same private dependency silently share whichever loaded first. Bare-DLL discovery byte-loaded the module's own assembly into both the Default and the isolated ALC, i.e. two distinct copies of the module's types while the module actually ran as the ALC copy. And hot-swap and rollback published the module's own entry assembly to the shared tracker, which Assembly.Loads a second copy into the non-unloadable Default ALC, while the swapped module already ran from its new ALC, so that copy served no one.

Hot swap now rolls back on any start failure, not only on a timeout. Other failures (a CreateContext throw, a DI fault) used to fall through to the outer catch, which did not restore the registry, did not unload the new ALC, and logged a misleading "staying on current version", leaving the old module unregistered and the registry pointing at a broken new one. The shared assembly tracker is switched to the new assembly only after it has started successfully.

Hot deployment of .tpkg stopped skipping a package forever. The scanner recorded a package's last-write time before trying to open and verify it, so a package that failed on that scan (a still-copying, half-written ZIP, or a signature not yet present) was recorded as "seen" and then permanently skipped until its mtime changed again. The time is now written only after a successful open. On top of that, a copy-stability debounce requires a new or changed .tpkg to hold the same size and mtime for N consecutive scans before it is opened.

A separate report from @MegasomaWT: RouteBuilderModule did not register its routes. Initialize called _builder.Configure(context), which only fills the builder's own definition list, while the context compiles only builders registered through RouteContext.AddRoutes(...). The routes never reached the context, and the context started with zero endpoints and no error at all: "started successfully: all 0 endpoints operational". The previous mock-based test asserted that Configure was called, not that it had any effect, so it stayed green the whole time the bug was shipping.

Two build items as well. The image smoke gate was never running: -All did not set $Test, so the stage was skipped on every full pipeline run, the same gate that caught the 3.4.0 worker dying with SIGSEGV. Turning it back on immediately exposed a broken probe: the stack profile tested pidof supervisord, but supervisord is a python script, so the process name is python3 and the probe could never match. And redb.Route.Soap was missing from shared-manifest.psd1, the single source of truth for the shared connector layer, so a module using a soap:// endpoint would find no component in a Tsak worker.

redb.Tsak 3.7.2: 22 of 37 redb.Core settings never arrived

Tsak:Redb:StringCollation and Tsak:Redb:EnablePvtPrefilter, both added to redb.Core in 3.7, did nothing. Neither in context.json nor as Tsak__Redb__*: no behaviour changed and no diagnostic appeared, so the only way to find out was to read the Tsak source.

They were not special. Both config paths copied properties across one hand-written line at a time, and the lists had gone stale: of the 37 public properties on RedbServiceConfiguration the named path carried 15 and the unnamed one 12, and the two sets were not subsets of each other, so the same key behaved differently depending on whether the instance had a name. Among the unreachable ones were all four DefaultCheckPermissionsOn*, SystemUserId, MissingObjectStrategy, IdResetStrategy and ThrowOnSchemeMismatch: behaviour and access control, not tuning knobs.

Both paths now run a reflection-based binder first, so a new RedbServiceConfiguration property is configurable the moment it exists; every hand-written assignment still runs afterwards and stays authoritative, so existing behaviour is unchanged, including the legacy *Minutes key spellings and the Tsak:Redb:Cache subsection with its own vocabulary.

The silence was the real defect. An unrecognised key used to vanish without a word, which is why a typo and a missing feature looked identical. Unknown keys are now reported, and the settings that were actually applied are logged at startup.

Worth keeping in mind when upgrading: keys that previously did nothing now take effect. If a deployed context.json carries something like DefaultCheckPermissionsOnQuery: true, it was inert before 3.7.2 and will not be after.

redb.Identity: a second facade and a shared middle

redb.Identity has been described more than once as transport-agnostic: the logic lives in redb.Identity.Core behind direct-vm://identity-* addresses, and HTTP is a facade over them. There was nothing to check that claim with, because there was exactly one facade.

Now there are two. gRPC sits beside HTTP: the same redb.Identity.Core routes, the same issuer, the same client registry, the same token store, with Token, Introspect, Revoke, UserInfo, Discovery and Jwks. Plus forty admin operations on the management port, each its own method address and its own route id. The identity.v1.proto contract ships in the redb.Identity.Contracts package, so a non-.NET client takes it from NuGet rather than from the repo. Full write-up with configuration and boundaries in the gRPC facade article.

Two things moved to make the second facade possible, and both are more interesting than the facade itself.

The management controllers were extracted into their own package, redb.Identity.Management: 33 files, 142 actions and not one line of business logic, every action validating its DTO and forwarding to a direct-vm://identity-manage-* route. They lived in the HTTP facade, where a second transport could not reach them without breaking facade isolation. Now the facades reference the package rather than each other, and the package references redb.Identity.Core nowhere. SCIM deliberately stayed in the HTTP facade: RFC 7644 is defined over HTTP, and ScimControllerBase reads redbHttp.Url to build Location headers.

The granular scope table moved into redb.Identity.Core, behind direct-vm://identity-authz-check. It used to live in the HTTP facade, and a second transport needed the same decisions. The failure mode of two copies of an authorization table is not that they disagree loudly, but that one of them quietly grants more than the other, on the surface where that matters most. Moved verbatim: same order, same write-implies-read rule, same identity:account branch, same default-deny, same refusal wording, because the HTTP negative matrix reads that wording and rewriting expectations is how a regression net stops catching anything. The route is registered unconditionally: a facade calling an address that is not there would fail open, which is the one direction an authorization gate must never fail.

The gate that read "no refusal" as success

The most instructive security item in the release.

A deployment can be wired without a management auth processor, which leaves direct-vm://identity-auth-management unregistered. The hop then throws "No consumer registered", the context-level exception handler marks that handled and replaces the body with its own error document. The gate saw exactly what success looks like: no refusal recorded, no non-2xx code.

The call still did not execute, but only because that handler happened to end the pipeline, and because the wire encoder happened to refuse the leftover dictionary. Two unrelated safety nets, neither of them an authentication decision. Remove either one and every admin operation runs unauthenticated.

The gate after the authentication hop now demands the identity:management-principal the auth processor leaves behind, and answers UNAUTHENTICATED when it is absent. Covered by a fixture that boots redb.Identity.Core without the processor and asserts both the status and, the part that actually matters, that no row was written.

A related story: several redb.Identity.Core processors end their work with exchange.Stop(), the per-IP limiter and the granular scope guard among them, and To hands it the facade's own exchange. That flag ended the facade's pipeline too, so the reply left as a raw dictionary that never went through status mapping or protobuf encoding. The security-relevant answers were exactly the ones losing their status. The hop into redb.Identity.Core now goes through Enrich on a CloneLinked exchange: same exchange id, same DI scope, same properties, its own stop flag.

What else was fixed

A duplicate is a conflict, not a database outage. The builder-level DbException handler treated every database error as transient: a unique-constraint violation was retried three times with backoff (it collides on the third attempt exactly as on the first) and then answered 503 "Database temporarily unavailable", telling the caller to come back later about something only they can fix. It now answers 409 with error: duplicate. Found through a SCIM demo that reused one e-mail address and was reported as a database outage, which is where the hunt started instead of ending.

The audit DDL learned to reconcile an existing table instead of silently skipping it. CREATE TABLE IF NOT EXISTS is a no-op when the table is already there, so a database created before a column existed never gained it. The audit query selects category and login; on such a database it failed, the DbException handler retried three times and answered "Database temporarily unavailable", which reads as an outage rather than a schema gap. The PostgreSQL and MSSQL scripts now add missing columns idempotently before the indexes, and Postgres additionally repairs two types that drifted after the first release: details from jsonb to text (dialect-agnostic parameter binding passes strings, and jsonb refuses them, so every audit write failed) and user_id from varchar to bigint, the latter only when every existing value is numeric, otherwise the table is left alone with a notice, because nulling unparseable identifiers is a data loss nobody agreed to.

And a documentation fix that would have cost real money in production: the README and doc/DEPLOYMENT.md described an option under a name no type matches, and got its default backwards. The docs promised that the shared signing-key store is on out of the box (the verification table even labelled it "(default)") while the code has it off. An operator running more than one replica read that the JWKS was shared, when replicas in fact diverge on keys by default. Corrected to the fact, with an explicit "off by default, switch it on for multi-replica". Archived documents, the records of finished sprints, were deliberately left alone: rewriting names inside them would falsify the record.

Finally, 3.7.1 fixed the reason the gRPC facade in 3.7.0 did not reach everyone. build-modules produces three .tpkg files, but the list of what gets copied into artifacts was hardcoded as Core.Module plus Http, in build-archives.ps1 in three places and in three Dockerfiles. The redb.Identity.Grpc package was published and announced while its .tpkg was built and silently dropped, so anyone installing from an archive or an image got no gRPC facade at all. The archive script now enumerates *.tpkg from the build output and prints what it ships, and the Dockerfiles copy by glob. A new module now ships because it exists, not because someone remembered to add a line.

A small thing everyone will notice

redb.Tsak and redb.Identity now ship XML documentation in their packages. GenerateDocumentationFile was never enabled in either, so a consumer of redb.Tsak.Client or redb.Identity.Core got no IntelliSense at all: the package held a bare .dll. Now lib/<tfm>/*.xml travels with every package, and for redb.Identity.Core alone that is 670 KB.

CS1591 (public member without XML doc) is suppressed there, unlike redb.Route where it is deliberately left on: turning the doc file on without that floods the build with warnings on members that predate the policy. The doc-syntax warnings stay visible on purpose, because they mark real defects, and there are plenty: redb.Identity.Core alone raises 218 of them, including 128 unresolved cref and 100 methods missing a param tag.

How to install

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

dotnet add package redb.Route
dotnet add package redb.Route.Grpc
dotnet add package redb.Route.Soap

The current number on the line is 3.7.2. The libraries target net8.0, net9.0 and net10.0; host applications, images and archives are built on .NET 10 and tagged -net10. Pro stays proprietary but free, with no licence key, across the whole 3.x line.

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

What's next

Several items in this release arrived as reports from outside, and some of them were not where they looked. That is the most valuable kind of incoming mail, worth more than any feature request, so thank you to everyone who wrote instead of quietly moving on. A feedback journal is kept in the repository, and every external signal lands there: valid, arguable and rejected alike, with an assessment and a link to where it was filed.

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

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