Релизы redb.Route
Подписаться на релизыВсе релизы redb.Route, от свежего к старому. Текст — как в репозитории, без пересказа.
4.2.0
Fixed — Controllers: a reply the action wrote itself reaches the caller
- An action may answer by itself: write
Exchange.Out(body,status.code,Content-Type), stop the exchange and returnnull. The generic, SignalR and gRPC dispatchers then cleared that body (a regression of the "void action no longer echoes the request" fix above), and the SignalR and gRPC dispatchers also replaced its status with204: a404with an error document reached the caller as a404with an empty body. The HTTP dispatcher had cleared such a body since June and, reading only its own default, sentredbHttp.ResponseCode: 204next to the action'sstatus.code: 404. - The request body is now cleared only when the dispatcher made the reply itself (a clone of the request): an
Outthat already exists is the action's reply and keeps its body and status. The HTTP dispatcher mirrors the winningstatus.codeinto a missingredbHttp.ResponseCode. A void action with nothing written still answers204with no body.
Breaking — a route with a bad static target no longer starts
- The endpoint of a static target —
To,WireTap,Enrich, a load balancer's targets, a Scatter-Gather's fixed recipients — is created when its route starts, as Camel resolves a staticto(...)at startup: an unknown scheme, an unknown URI parameter or a missing{{key}}stops the start (or skips the route withThrowOnCompilationError = false). It used to surface on the first message sent there, hours after a deploy. - A dynamic target (
ToD, a recipient list, a routing slip) is still resolved per message, and producers are still made when a message first needs one — unlike Camel, which starts them with the route: starting a context opens no connection to a broker a route has not used yet. A static target matched by an intercept withSkipSendToOriginalEndpoint()is not created up front (a dry run whose real component is absent).
Added — Route-XML <aggregate forceCompletionOnStop>; VS Code extension 0.2.16
- The attribute of Apache Camel
forceCompletionOnStop(04edaf35): a group still open when the context stops completes with what it has and goes on down the route. It is parsed onto.ForceCompletionOnStop(bool)(an explicitfalseis kept, as the fluent call keeps it), printed by the C# generator, and known to the schema, the editor (redb-route-1.0.catalog.xsd,redb-route-elements.json) andcheck. - The
eip.route.xmlexample sets it on its aggregator; the generatedEipRoutes, the golden tree and the hand-written C# equivalent prove the XML and the C# build the same definition. - Extension version 0.2.16 for the schema change.
Breaking — a typed read of a header, a property or a received body fails on a value it cannot parse
GetHeader<T>,GetProperty<T>and the typed receive ofConsumerTemplateconvert as Camel does: a missing value, or one with no conversion to the type at all, reads as default; a value the conversion exists for but cannot parse ("abc" as an int, an overflow) throwsFormatExceptionnaming the key and the type, never the value. It used to read as default, so a typo in the data sent the route down its "no value" branch without a word.- Numbers and dates are read in the invariant culture:
"1.5"read asdecimalwas 0 under a culture with a decimal comma. A nullable target converts like its underlying type:"5"read asint?was null.
Fixed — an exchange re-entering a route stays in flight until its last entry finishes
- A copy keeps the exchange id, so an exchange entering a route again (a route calling itself through
direct:, parallel branches through one sub-route) registered the same id twice, and the first entry to finish took it off the inflight repository while the others still ran:Browse()andCountByRouteshowed less than was in flight.DefaultInflightRepositorynow counts the entries.
Changed — the endpoint byte count no longer serializes an object body
BytesInmeasures text, bytes and a seekable stream; any other object counts as 0, as Camel counts no bytes at all. It used to be serialized to JSON on every incoming exchange only to count the bytes of the result.
Fixed — bean: awaits a method returning ValueTask or ValueTask<T>
- A bean method returning
ValueTask<T>put the struct itself into the body instead of its result, and one returningValueTaskwas not awaited: its side effect was lost and the body overwritten with the struct. Both are awaited now, asTaskandTask<T>: the result of aValueTask<T>becomes the body, aValueTaskleaves the body as it was.
Breaking — an aggregation group holds exchanges of its own and completes in a DI scope of its own
- The aggregator kept the caller's exchange itself, and the caller disposes it as soon as the aggregator returns: a group completing later — on a timeout, on stop, or by a later arrival when the strategy keeps the first exchange — went on down the route with the bodies closed and the DI scope released. Now, as Camel copies an exchange into its aggregation repository, every arrival is taken over (its bodies included), the completed group runs on in a DI scope of its own, and everything the group held is released once, after it has gone on or been dropped.
- The steps after the aggregator run on the group's exchange, never on the caller's: what they write is not seen on the exchange that fed the group. A group completed by an arrival still fails that arrival when it fails.
Added — forceCompletionOnStop on the aggregator; open groups are no longer dropped without a word
.Aggregate(...).ForceCompletionOnStop()(Apache CamelforceCompletionOnStop): an aggregation group still open when the context stops completes with what it has and goes on down the route. It runs once the consumers have stopped — nothing more arrives — and before the producers stop, so what it sends still goes out.- Without it an open group is dropped on stop, as Camel drops an in-memory repository, now with a warning naming how many and its exchanges released; the same when the context is disposed without a stop. It used to vanish silently.
Fixed — route metrics and the route span count exchanges, not redelivery attempts
- The route span and
redb.route.exchanges.*sat inside theOnExceptionhandlers declared on the route builder, so each redelivery attempt went through them again: one exchange redelivered twice was three spans and three failures. They now sit outside every error handler, like the endpoint statistics: one exchange, one span, one measurement with its final outcome. An exchange an error handler gave up on (the failure left on it, not thrown) now counts as failed, not processed.
Fixed — a retry waits the delay its policy computes
RetryProcessoradded ±15% jitter of its own on top ofRetryPolicy.GetDelay: jitter twice when the policy had aCollisionAvoidanceFactor, jitter nobody configured when it had none, and a wait pastMaxDelay. It now waits the policy's delay as it is; jitter comes only from the policy (off by default, as Camel's collision avoidance).
Breaking — seda: and vm: queues are bounded by default: 1000, as Camel
sizedefaults to 1000 (was 0, unbounded). A full queue makes the sender wait up totimeout(30 s by default), as a bounded queue always did. A consumer slower than its senders no longer grows the queue until the process runs out of memory; a route that relied on it being unbounded writessize=0.
Breaking — Controllers: a value that does not convert is a binding error, not the type's default
- A header, query, route, property or positional value that was present but did not convert to the parameter
type bound silently as the type's default:
?page=abcreached anint pageas0. It is now an error that names the parameter and the value (Parameter 'page': 'abc' is not a valid Int32.), as Camel fails bean parameter binding withParameterBindingException. A missing value still binds as the parameter's default. - The HTTP, SignalR and gRPC dispatchers answer it with
400 BadRequestand that message; SOAP with the Sender fault it already used for a header that does not bind; the direct-invoke DSL overloads fail the exchange. - The generic dispatcher (
RedbController(registry),RedbController<T>()) now draws the same line as the transport dispatchers: a failure while binding is the caller's400(logged as a warning), the same exception inside the action stays a500. It answered500for both before.
Fixed — Controllers: a void action no longer echoes the request as its reply
- The generic, SignalR and gRPC dispatchers build the reply as a clone of the request, and on a
void(orTask) action they set204without clearing the body, so the caller got its own request back: the request payload on the generic dispatcher, the hub arguments on SignalR, the request bytes on gRPC. The body is now empty, as the README's response table says and as the HTTP dispatcher already did.
Fixed — Controllers: header, query, route and property values bind the same on every server culture
[FromQuery],[FromHeader],[FromRoute]and[FromProperty]converted text with the thread's culture:amount=12.5bound125for adecimalon a de-DE server and silently0on ru-RU. The value is wire text and is now parsed with the invariant culture.- A
DateTimeOffsetparameter never bound from text: the type is notIConvertible, the conversion failed and the parameter silently becamedefault. It is now parsed (invariant culture), and an unparsable value fails the binding like an unparsableGuiddoes (400on the HTTP, SignalR and gRPC dispatchers).
Breaking — Cache: a hit never replays another caller's headers; put stores only the headers it names
CacheHeaders()stored every header of the message, and a hit wrote them all over the current exchange. WithFrom("http:…")the second caller's HTTP response carried the first caller'sAuthorizationandCookie, and a backend called afterEndCache()received them as well (the consumer stripsAuthorizationonly when it checks it itself). The entry now keeps what the inner steps did to the headers, compared by instance with what the exchange came in with: the headers added or changed, and the names removed. A hit does the same to the current exchange and leaves its other headers alone. With the reply inOut, what the steps did toIncomes back on a hit as well; it did not before.Authorization,Proxy-Authorization,CookieandSet-Cookieare never stored, whichever form or step produced them.- The
cache:component:putstores only the headers named withheaders=X-Rate,Content-Language.cacheHeaders=trueis refused with the reason (aputcannot tell the headers of the value from the headers of the request), naming a credential is refused, andheaders=on any other action is refused.
Fixed — a copy of an exchange no longer disposes the body its origin goes on with
- A body that needs disposing (a stream, a
StreamCache) is disposed by the exchange that owns it, once, when that exchange is done, as a Camel unit of work cleans up and a copy does not. A copy shares its origin's body by reference and used to dispose it: RecipientList, Splitter and Scatter-Gather with an aggregation, and a copying Loop, closed the body the route went on with (the aggregated or merged-back result included), and.Threads()lost its body to the caller disposing the original while the worker still read it. Now a copy disposes only a body no ancestor carries or lent it; the origin disposes its own and the ones it lent, so a body replaced by an aggregation is not leaked either..Threads()hands the body over to the worker's copy. An exchange disposed twice, or with one body inInandOut, disposes it once; one body failing to dispose is logged and does not strand the others or the DI scopes.
Fixed — Controllers: async Task, ValueTask and ValueTask<T> actions reply as documented
- The dispatchers and the direct-invoke DSL overloads took an action's result from the run-time type of the task
it returned. An
async Taskmethod really returns aTask<VoidTaskResult>, so its runtime-internal result became the reply:200with{}on HTTP and gRPC, the struct itself on the generic and SignalR dispatchers, a Receiver fault on SOAP (the XML serializer refuses the internal type). Such an action now replies204with no body, as the response table in the README says, and an empty body on SOAP. - A
ValueTask<T>action replied with theValueTask<T>struct instead of its result, and anasync ValueTaskaction was not awaited, on every dispatcher but SOAP. Both are awaited now and reply likeTask<T>andTask. RedbController<T>(methodName),RedbController(registry, controllerName, methodName)andRedbController(registry, controllerExpr, methodExpr)let an action's synchronous exception reach the route wrapped inTargetInvocationException, soOnException(typeof(...))for the action's own exception type did not match. The action's exception now reaches the route as it was thrown, with its stack trace.- All of them go through one invocation step that reads the shape from the method's declared return type, as
ASP.NET Core's
ObjectMethodExecutordoes. An action declared to returnobjectis not awaited even if the object it returns is a task.
Added — Quartz consumer spans
- The
cronandqtimerconsumers open a rootConsumerspan per fire,{scheme} receive, with the route under it: a fire carries no trace context, so it is never a child of the activity the scheduler thread holds. A failed route marks it an error, our own stop does not;EnableTelemetry = falseopens none. Was: no inbound span.
Fixed — ProducerTemplate stops its producers: they flush and close
ProducerTemplate.Stop()andDispose(), and the newDisposeAsync(), stop the producers the template created, as a Camel template stops its producer cache; the context stopping stops them too, with the template left open. They were only dropped from the cache: a Kafka producer behind a template (the Llm tool bridge, TestKit sends) lost what it had not sent yet when the host stopped. A producer is stopped once, by whichever stops it first; a restarted template creates new ones. The context's list of producers to stop is now safe to add to from several threads.
Fixed — SignalR: the hub works on .NET 9
- Since 4.0.0 the hub the consumer hosts closed every client connection with "Connection closed with an error"
when the application ran on
net9.0. The package referencedMicrosoft.AspNetCore.SignalR.Client9.0.3 andMicrosoft.AspNetCore.SignalR.Protocols.MessagePack10.0.11 on every target framework, so a newerMicrosoft.AspNetCore.SignalR.Commonlanded next to the older hub of the shared framework. Both packages now follow the target framework: 8.0.31 onnet8.0, 9.0.20 onnet9.0, 10.0.12 onnet10.0, and the framework's own SignalR assemblies are used on each. The README no longer carries the .NET 9 warning.
Fixed — Cache: KeyFromBody() hashes a stream, and names itself when a body cannot be hashed
- A
Streambody went toSystem.Text.Jsonas an object and failed on the stream's own properties ("Timeouts are not supported on this stream"). It is now read to the end and hashed, and the message continues with its bytes, as it does when a miss is stored. - An object body is hashed through the context's data format for the message's content type, the rule the
distributed store serializes it by. It was the default
JsonSerializer, whateverConfigureJsonCodecsaid, so a key computed for an object body by an earlier version may differ, and that entry is computed once more. A body that cannot be serialized is anInvalidOperationExceptionnamingKeyFromBodyand the type. - Only the body is hashed: the content type and the headers are not part of the key.
- The distributed store resolves a body type by its name once per type, and closes
Serialize<T>over a body type once, instead of walking the loaded assemblies and building the call on every read and write.
Added — Firebase consumer spans; the producer spans honour EnableTelemetry and mark failures
- The Firestore consumer, realtime and polling, opens a root
Consumerspan per routed change,firestore {collection} receive; the Storage consumer one per routed object,gcs {bucket} receive. A change or an object carries no trace context, so it is never a child of the activity the consumer inherited; nothing arriving opens none. A failed route marks it an error, our own stop does not. Was: no inbound span. - The FCM, Firestore and Storage producer spans are errors when the call fails, unless our own token cancelled it, and
EnableTelemetry = falseopens them no more. Was: always opened, never marked.
Fixed — Cache: one computation per key across the cache nodes of a context
- The single flight on a miss belonged to the node: two scopes on the same region and key shared the store and still computed the entry once each. The flights now belong to the context and the provider, as the store does, so every node on one store waits for the first one's result. Other processes, and other contexts, compute on their own; a failed computation is not cached, so the waiters retry one at a time.
CacheScopeProcessorno longer keeps a region it never read: the region is part of the store key.
Fixed — Cache: cache:…?action=get hands a cached reply back as a reply
- An entry the caching scope stored out of
exchange.Out(a request-reply producer replies there) keeps that fact, and the scope restores it toOuton a hit. The component ignored it and wrote the entry intoIn, so a route reading the same entry withaction=getanswered a request-reply consumer with the request body.getnow restores the entry where the miss left it.putstores the message as it reaches the step: the pipeline has folded the previous step's reply intoInby then.
Breaking — Cache: a value under our key that is not a redb.Route cache entry is an error, not a hit
- The JSON envelope of the distributed store carries a format marker and a version now. A value under a cache
key that is not JSON, has no marker, or has a version this build does not read is an
InvalidOperationExceptionnaming the key: another writer shares the key space (give this cache its ownInstanceName, or another region), or the entry was written byredb.Route.Cachebefore this version (clear the region, or let the entries expire). Was: foreign JSON deserialized into an empty envelope and counted as a hit with anullbody,cache.hit = true, and the inner steps skipped; non-JSON bytes threw aJsonExceptionwithout the key. - Entries written by earlier versions have no marker and are refused the same way, so a distributed cache
shared across an upgrade needs its regions cleared once (or a new
InstanceName).
Added — MCP tools/call span honours EnableTelemetry and marks failures
mcp.tools/call {server}/{tool}carriesredb.route.endpoint, is not opened withEnableTelemetry = false, and is an error when the call fails (an unknown or dead server, a tool error, the call timeout), unless our own token cancelled it. Was: always opened, never marked.
Breaking — Telegram tracing on the core's transport contract
- The receive spans are roots. They were children of whatever activity the polling loop held. They now carry
redb.route.endpoint, honourEnableTelemetry, and are errors when the route fails, the failure left on the exchange included; our own stop is not. Was: marked only when an exception escaped the route. - The send span carries
redb.route.endpointand honoursEnableTelemetry; a failed send, the per-request timeout included, marks it an error unless our own token cancelled it.
Breaking — Llm tracing: a root span per scheduled tick; the model span honours EnableTelemetry and marks failures
- The scheduled consumer opens a root
Consumerspan per tick,llm {factory} receive, with the model call and the route under it; a failed tick (the model call or the route) marks it an error, our own stop does not. Was: anllm.consumer.errortag on whatever activity happened to be current, and the tick's spans under that activity. llm {provider}:{model}andllm {provider}:{model} streamcarryredb.route.endpoint, are not opened withEnableTelemetry = false, and are an error when the run fails, unless our own token cancelled it. Astream=bodyrun cancelled by another token (an HttpClient timeout) now counts as an endpoint error. Was: always opened, never marked.
Fixed — Cache: clear on a distributed cache removes a sliding key that reads kept alive
DistributedCacheStorekeeps the keys this process wrote, each with the moment it stops mattering, and sweeps the expired ones every 256 writes (IDistributedCachecannot enumerate, so region-wideclearworks from that list). A sliding entry lives on with every read, but the list remembered the write plus the window only, so a key read all the time was swept andclearleft it in place. A hit now renews the key's lease, capped by the absolute TTL when there is one.
Fixed — Cache: an out-of-range duration is a FormatException like any other typo
CacheDuration.Parse("9999999999d")threwOverflowExceptionout ofTimeSpan, andNaNsanArgumentException, while every other unusable duration is aFormatExceptionnaming the text. Now all of them are.
Fixed — Cache: clear on the in-process cache removes an overwritten key
MemoryCacheStorekeeps the keys it wrote in a list for the region-wideclear(IMemoryCachecannot enumerate) and took a key off it from the entry's eviction callback.MemoryCachefires that callback for the entry a secondputof the same key replaces as well, on the thread pool, after the new entry is in, so a key written twice vanished from the list while its entry lived on, andclearleft it in place. The callback now ignores a replacement and takes a key off only while the list still holds that entry's own write.
Breaking — Mail tracing on the core's transport contract
- The IMAP and POP3 receive spans are roots. They were children of whatever activity the poll loop had inherited
from the code that started the routes. They now carry
redb.route.endpoint, honourEnableTelemetry, and are errors when the route fails, the failure left on the exchange included; our own stop is not. Was: marked only when an exception escaped, a stop included. - The SMTP send span carries
redb.route.endpoint, honoursEnableTelemetryand is an error when the send fails, unless our own token cancelled it. Was: never marked.
Added — MQTT trace context in v5 user properties; consumer spans; the producer span honours EnableTelemetry and marks failures
- On a v5 connection the producer writes the W3C context to user properties, replacing a
traceparentcopied from an earlier hop intoredbMqtt.userProperties; the consumer reads it and parents its span on it. Under 3.1.1 nothing is written (MQTTnet throwsNotSupportedExceptionfor user properties there) and the receive span is a root. Was: no context written. - The consumer opens a
Consumerspan,{topic} receive, per routed message, serial and concurrent dispatch alike; without a context it is a root, never a child of the client's receive loop. A failed route marks it an error, our own stop does not. A cancellation that is not our own is now logged as a failure instead of being dropped. Was: no inbound span. {topic} publishis an error when the publish fails, unless our own token cancelled it, andEnableTelemetry = falseopens it no more. Was: always opened, never marked.
Breaking — Exec tracing: the client span named after the resolved executable; a span per scheduled tick
- The producer's client span is named
exec {executable}and carriesprocess.executable.name= the file name of the command actually resolved (URI option, header or JSON body), withprocess.exit.code. Was:exec {command option}(exec <dynamic>for a command from a header or body) andprocess.executable.name = "exec". The arguments never reach the span. A refused command, a failed start or a cancellation that is not our own marks it an error; it honoursEnableTelemetry. - The scheduled consumer opens a root
Consumerspan per tick,exec {executable} receive, with the run and the route under it; a failed tick marks it an error, our own stop does not. Was: anexec.consumer.errortag on whatever activity happened to be current, lost when there was none.
Added — WebSocket consumer spans linked to the handshake; the producer span honours EnableTelemetry and marks failures
- The consumer opens a root
Consumerspan,ws {path} receive, per routed message, the InOut reply included. A frame carries no headers: when the client senttraceparentwith its upgrade request, every span of the connection links that context, and the connection's own activity is never the parent. A failed route marks the span an error; our own stop does not. Was: no inbound span. ws send {mode}is an error when the send fails, unless our own token cancelled it, andEnableTelemetry = falseopens it no more. Was: always opened, never marked.
Added — SignalR consumer spans linked to the handshake; the producer span honours EnableTelemetry and marks failures
- The consumer opens a root
Consumerspan,signalr {hubPath} receive, per exchange that reaches the route (each invocation and theConnected/Disconnectedevents). A hub message carries no headers: when the client senttraceparentwith its connection request, every span of the connection links that context, and the connection's own activity is never the parent. A failed route marks the span an error; the connection's own abort does not. Was: no inbound span. signalr {mode} {method}is an error when the send fails, unless our own token cancelled it, andEnableTelemetry = falseopens it no more. Was: always opened, never marked.
Added — LDAP consumer spans; the producer span is a Client span that honours EnableTelemetry and marks failures
- The WATCH consumer opens a root
Consumerspan,ldap {baseDn} receive, per routed entry: an entry carries no trace context, so it is never a child of the activity the poll loop inherited; a poll that finds nothing opens none. A failed route marks it an error, our own stop does not. Was: no inbound span. ldap.{operation}is aClientspan withredb.route.endpoint, an error when the operation fails unless our own token cancelled it, andEnableTelemetry = falseopens it no more. Was: anInternalspan without the endpoint tag, always opened, never marked. A cancellation that is not our own now also counts as an endpoint error.
Fixed — Elasticsearch consumer: a document whose route failed is no longer deleted with deleteAfterRead
- A failure that escaped the route's pipeline — a cancellation inside the route (an HttpClient timeout is a
TaskCanceledException), which the error handlers pass on — was logged and dropped without reaching the exchange, sodeleteAfterRead=truetook the route for a success and deleted the document it never processed. The failure now goes on the exchange, and the document stays, as for any other failed route.
Added — TCP consumer spans; the producer span honours EnableTelemetry and marks failures
- The consumer opens a root
Consumerspan,tcp {host}:{port} receive, per routed message: a framed message carries no trace context, so it is never a child of the activity the listener inherited. A failed route marks it an error, our own stop does not. Was: no inbound span. tcp {host}:{port}is an error when the send fails, unless our own token cancelled it, andEnableTelemetry = falseopens it no more. Was: always opened, never marked.
Added — Elasticsearch consumer spans; the producer span honours EnableTelemetry and marks failures
- The consumer opens a root
Consumerspan,es {index} receive, per routed hit: a document carries no trace context, so it is never a child of the activity the poll loop inherited; an empty poll opens none. A failed route marks it an error, our own stop does not. Was: no inbound span. es {operation}is an error when the call fails, unless our own token cancelled it, andEnableTelemetry = falseopens it no more. Was: always opened, never marked.
Added — Redis consumer spans; the producer span honours EnableTelemetry and marks failures
- The consumer opens a root
Consumerspan,redis {operation} receive, per routed pub/sub message, stream entry or list item: a Redis value carries no trace context, so it is never a child of the activity the consumer loop inherited. A failed route marks it an error, our own stop does not. Was: no inbound span. It is a messaging span:messaging.system = rediswith the channel, stream or list asmessaging.destination.name(the producer's command span keepsdb.system = redis). redis {operation}is an error when the command fails, unless our own token cancelled it, andEnableTelemetry = falseopens it no more. Was: always opened, never marked.
Fixed — file consumers: a cancellation out of the route fails the file, not the poll
file,ftp,sftp: a cancellation coming out of the route that is not the consumer's own stop — an HttpClient timeout is aTaskCanceledException, and an error handler passes a cancellation on — escaped the per-file handling and failed the whole poll. The failure path of that file did not run (moveFailedleft it in place), the files after it in the listing waited for the next poll, and the poll counted as an error for the backoff. It is now a failure of the file like any other exception; only the consumer's own stop still ends the poll.
Added — File, FTP and SFTP consumer spans; the producer span honours EnableTelemetry and marks failures
- The consumers built on
redb.Route.GenericFile(file,ftp,sftp) open a rootConsumerspan,file read {scheme}, per routed file. A file carries no trace context, so it is never a child of the activity the poll loop inherited; an empty poll opens none. A failed route marks it an error, our own stop does not. Was: no inbound span. file write {scheme}is an error when the write fails, unless our own token cancelled it, andEnableTelemetry = falseopens it no more. Was: always opened, never marked.
Added — S3 consumer spans; the producer span honours EnableTelemetry and marks failures
- The consumer opens a root
Consumerspan,{bucket} receive, per routed object. An object carries no trace context, so it is never a child of the activity the poll loop inherited; an empty poll opens none. A failed route marks it an error, our own stop does not. Was: no inbound span. s3 {operation}is an error when the call fails, unless our own token cancelled it, andEnableTelemetry = falseopens it no more. Was: always opened, never marked.
Added — SQL consumer spans; producer spans honour EnableTelemetry and mark failures
- The consumer opens a root
Consumerspan,sql.receive, per routed exchange: a row, or the list of a batch. A row carries no trace context, so it is never a child of the activity the poll runs under. A poll that routes nothing opens none; one that routes an empty exchange (routeEmptyResultSet,sendEmptyMessageWhenIdle) opens one. A failed route marks it an error, our own stop does not. Was: no inbound span. sql.executeandsql.procedureare errors when the statement fails, unless our own token cancelled it, andEnableTelemetry = falseopens neither. Was: always opened, never marked.
Breaking — SQS and SNS tracing on the core's transport contract
- A message without context opens a root receive span. It was a child of whatever activity the receive loop had
inherited from the code that started the routes, so unrelated messages hung under it. With a
traceparentthe span is its child, as before; the sender's baggage is now back on it. - The receive span carries
redb.route.endpointand is an error when the route fails, a timeout inside it included; the consumer's own stop is not. Was: never marked. The send and publish spans are errors when the call fails, unless our own token cancelled it. EnableTelemetry = falseopens none of these spans; the ambient context still goes out in the message attributes. Was: the send and publish spans opened anyway.- More than 10 message attributes fail the send with a message naming how many came from the exchange headers and how many from the trace context; nothing is dropped. Was: sent as is, for the service to refuse with a bare count (LocalStack accepts it).
Breaking — IBM MQ tracing on the core's transport contract
- A message without context opens a root receive span, on the poll and the listener path alike. It was a child of
whatever activity the receive loop had inherited from the code that started the routes, so unrelated messages hung
under it. With a
traceparentthe span is its child, as before; the sender's baggage is now back on it. - The receive span carries
redb.route.endpointand is an error when the route or the settlement fails; the send span carriesredb.route.endpointand is an error when the send fails. EnableTelemetry = falseopens neither span; the ambient context still goes out in the message properties. Was: both spans opened, and nothing was written without a send span.- A missing trace property is told apart by its reason code (MQRC 2471); any other failure to read one is no longer swallowed.
- A cancellation is an error on either span unless it is our own: the consumer's stop (a message interrupted by it stays unmarked, on both receive paths) or the caller's token cancelling the send. Any other cancellation, a timeout inside the route for one, is a failure.
Breaking — AMQP tracing on the core's transport contract
- A message without context opens a root receive span. It was a child of whatever activity the receive loop had
inherited from the code that started the routes (a host startup span, a request), so unrelated messages hung under
it. With a
traceparentthe span is its child, as before; the sender's baggage is now back on it. - The receive span carries
redb.route.endpointand is an error when the route or the settlement fails; the send span carriesredb.route.endpointand is an error when the send fails. EnableTelemetry = falseopens neither span; the ambient context still goes out in the application properties. Was: both spans opened, and nothing was written without a send span.- A cancellation is an error on either span unless it is our own: the consumer's stop (a message interrupted by it stays unmarked) or the caller's token cancelling the send. Any other cancellation, a timeout inside the route for one, is a failure.
Breaking — SOAP tracing on the core's transport contract
- The consumer's server span continues the caller's trace.
soap receivetakes the host's ASP.NET Core span of the request when the application instruments ASP.NET Core, else the caller'straceparentheader, else it is a root; the caller's baggage is back on it. Was: a root unless the receiving thread happened to hold an activity, so the caller's trace ended at redb. It honoursEnableTelemetryand is an error when the route fails. - The server span covers the whole request, the ones refused before the envelope is read (malformed MTOM) included. As a 5xx against a 4xx: a Receiver/Server fault, thrown or declared on the reply, and any other failure of the route are errors on it, a cancellation included unless the caller went away (a timeout inside the route got a bare 500 and an unmarked span; it now gets a fault with the exchange reference); a malformed request or a Sender/Client fault is an answer to the caller and is not.
- The client span is an RPC span:
rpc.method= the action;messaging.operation(the action) andmessaging.destination.name(the URL) are gone. The request carries the span's context written by the producer, not left to HttpClient instrumentation, and the span is an error for a failed call, an HttpClient timeout included (only our own token cancelling it is not), or asoap:Faultreply.
Breaking — gRPC tracing on the core's transport contract
- The consumer's server span continues the caller's trace.
grpc receivetakes the host's ASP.NET Core span of the request when the application instruments ASP.NET Core, else the caller'straceparentmetadata, else it is a root; the caller's baggage is back on it. Was: a root unless the receiving thread happened to hold an activity, so the caller's trace ended at redb. It honoursEnableTelemetryand is an error when the call fails; the caller cancelling the call is not a failure, any other cancellation (a timeout inside the route) is. - The producer sends the context of its own span. A proxying route sent the caller's
traceparentthe header bridge copied into the metadata, and the HTTP handler under Grpc.Net.Client does not overwrite a trace header already on the request, so the next hop hung under the caller instead of under the call. The client span is an error for a failed call.
Added — Azure Service Bus tracing on the core's transport contract
- The consumer, and the session consumer, open a
Consumerspan per message,{entity} receive, over the whole unit of work, settlement included: a child of the sender's context, or a root when the message carries none — never a child of the processor thread's activity. The sender's baggage is back on it, the route's spans are its children, it carriesredb.route.endpointandmessaging.message.id, and a failed route marks it an error. Was: no inbound span. - The context travels in the application properties: the
traceparentvalue under Azure's own name,Diagnostic-Id,tracestateandbaggageunder their own names. The producer writes the context of its send span to every message, each message of a batch included, replacing aDiagnostic-Idthe header bridge copied from a received message. Was: the copied value went out as is, so the next hop hung under the previous one. - The send span honours
EnableTelemetryand is an error when the send fails. With tracing off no span opens, and the ambient context still goes out. - A cancellation is an error on either span unless it is our own: the processor stopping (the receive span of a message interrupted by the stop stays unmarked) or the caller's token cancelling the send. Any other cancellation, a timeout inside the route for one, is a failure.
Breaking — Kafka tracing on the core's transport contract
- A batch is a root linked to every record. With
maxPollRecords, the receive span took the first record's context as its parent and ignored the others' traces. It is now a root of its own with anActivityLinkto the context of every record that carries one;messaging.batch.message_countstays, and the per-record tags (partition, offset, key) are left to the single-record span. - The sender's baggage is back on the receive span of a record, so the route's own spans and sends carry it on; it was
dropped. A record without
traceparentstarts a root span, as before. - Both spans carry
redb.route.endpoint(sanitized),EnableTelemetry=falseopens neither, and a failed send (an immediate one, or a Kafka transaction of its own) marks its span an error. The send span's context replaces atraceparentcopied from a consumed record's headers, in whatever case it was copied: a routehttptokafkabridged the caller'sTraceparent, which the consumer, reading the name without case, took ahead of the send's. - A route failing on a record or a batch marks the receive span an error. Only the consumer's own stop does not: any
other cancellation is a failure, an HttpClient timeout inside the route (a
TaskCanceledException) for one. The same rule holds for the send span: only the caller's own token cancelling it is not a failure.
Breaking — AS2 tracing on the core's transport contract
- Span names carry the destination. The receive span of a message receiver and of an MDN receiver is
"{path} receive"(it wasAS2 receive/AS2 receive MDN), the send span"{partner AS2 id} send"(it wasAS2 POST). A dashboard or alert that selects spans by the old names needs the new ones. - The receive span takes its parent from the host's request span when the application traces ASP.NET Core, else from
the request's
traceparent, else it is a root; the sender's baggage is put back on it. It carriesredb.route.endpoint. Every refusal marks it an error: on the message receiver a refused message, a body overmaxRequestBodySizeand a failed route; on the MDN receiver an unreadable MDN and a receipt the signature policy refuses (both answered 400), a body overmaxRequestBodySize, and a failed route (answered 200: the partner did deliver its receipt). - The send span writes its own context to the request, replacing a
traceparentbridged from the exchange: HttpClient writes none when the header is already there, so aFrom(http) -> To(as2)route passed the caller's context on instead of the send's. A failed send (unreachable partner, no answer withintimeout, HTTP error, an MDN that does not confirm the transfer withRequireValidMdn) marks the span an error; a send the caller cancels does not. EnableTelemetry=falseopens neither span; a context the route received still reaches the partner.
Breaking — AS4 tracing on the core's transport contract
- Span names carry the destination. The receive span is
"{path} receive"(it wasAS4 receive), the send span"{partner} send"(it wasAS4 send). A dashboard or alert that selects spans by the old names needs the new ones. - The receive span takes its parent from the host's request span when the application traces ASP.NET Core, else from
the request's
traceparent, else it is a root; the sender's baggage is put back on it. A refused message (every ebMS error and SOAP fault answered) or a failed route marks it an error. - The send span writes its own context to the request, replacing a
traceparentalready there. An unreachable partner, a response without a receipt, an ebMS error or a receipt that does not verify marks it an error. EnableTelemetry=falseopens neither span; the caller's context still reaches the partner.
Breaking — RabbitMQ tracing on the core's transport contract
- The receive span takes its parent from the message only. A delivery without
traceparentstarts a root span; it was a child of whatever activity the client's dispatch thread held, usually the one around the consumer'sStart, so unrelated messages landed in one trace. Withtraceparentnothing changes. - The sender's baggage is back on the receive span, so the route's own spans and sends carry it on; it was dropped.
- Both spans carry
redb.route.endpoint(sanitized), a failed pipeline, publish, broker confirm, returned message or request-reply call marks its span an error, andEnableTelemetry=falseopens neither span. The send span's context replaces atraceparentcopied from the incoming message. - The receive span is an error also when the ack or the RPC reply fails after a successful route; a stop that cancels the route is not an error.
- Only a cancellation through the connector's own token (a stop) leaves either span clean; any other, such as an HTTP call inside the route timing out, marks it an error.
Breaking — tracing: one transport contract in the core; HTTP requests traced; EnableTelemetry=false covers transport spans
RouteEngineOptions.EnableTelemetry = falsenow opens no transport span either, for connectors that open theirs through the context (StartTransportSpan(context, ...),StartConsumerSpan), so a trace never holds a transport span without the route it belongs to. A context that came in is still passed on to the next hop. Was: only the route spans went away.- The HTTP consumer opens a
Serverspan per request,{method} {path}, over the whole request: a child of the host's ASP.NET Core span when the application instruments ASP.NET Core, else of the caller'straceparent, else a root. The caller's baggage is back on it, the route spans are its children, and it carriesredb.route.endpointandhttp.response.status_code; a 5xx marks it an error. Was: no inbound span, and the caller's trace ended at redb unless the host instrumented ASP.NET Core. - The HTTP producer sends the context of its own span. A proxying route (
httpin,httpout) sent the caller'straceparentcopied by the header bridge, andHttpClientdoes not overwrite a trace header already on a request, so the next hop hung under the caller instead of under the send. The client span now carrieshttp.response.status_codeand is an error for a 4xx, a 5xx or a failed call. - A cancellation marks an HTTP span an error unless the caller asked for it: the consumer's request aborted by the
client, or the producer's own token. An HttpClient timeout (a
TaskCanceledException) failed the call and left the send span unmarked; a route that threw such a cancellation got Kestrel's bare 500 and an unmarked server span, and now gets the consumer's 500 with the exchange reference.
Added — RouteTelemetryExtensions: the tracing contract of a transport
StartConsumerSpan(context, ..., carrier, readHeader, parent)opens an inbound span: the parent from the message's W3C fields, or a root when there are none — never the receiving thread's ambient activity, whichStartActivity(parentContext: default)otherwise adopts — with the sender's baggage put back;InboundParent.HostRequesttakes the host's span of the same request first. Disposing it gives the thread its ambient activity back.InjectTraceContext(activity, carrier, writeHeader)writestraceparent,tracestateand baggage to an outgoing message, from the send span or else the ambient activity, replacing a copied value.RecordFailure(activity, exception)marks a span failed: error status and an OpenTelemetryexceptionevent, the shape the route spans use.ExtractTraceContext(...)reads one message's context, for batch links.IsTracingEnabled(context)andStartTransportSpan(context, ...)honourEnableTelemetry.DrainableConsumer.ProcessWithTracking(exchange, span, ct)runs the route inside a receive span the consumer opened and marks it by the same rule: a failed route, whether its failure stayed on the exchange or escaped the pipeline, and any cancellation but the consumer's own stop. A polling consumer takes it instead of a copy of its own. Both overloads now put a failure that escaped the pipeline (a cancellation an error handler passed on) on the exchange: it was logged and dropped, so a consumer deciding on the outcome afterwards saw a clean route.- A connector supplies the carrier and its names; the W3C and baggage handling is written once. Brokers that carry their own copy move onto it with their owners.
Added — [ConnectionParameter]: a connection factory is the whole connection, for every connector
[ConnectionParameter]on an options property says a named connection factory sets it (host, credentials, TLS, timeouts);[ConnectionFactoryReference]marks the option that names the factory, so the core does not guess it. Written beside the factory reference in a URI, a connection parameter is refused by the core when the options are bound, for every connector that declares them — as in Camel, where "all connection options set on URI are not used", except that here it is an error instead of a silent loss. The message names the factory and the parameters, never their values.EndpointOptions.WrittenBesideConnectionFactory(...)answers the same question for tooling. An options type that declares connection parameters without a factory reference, or with two, is a declaration error.- RabbitMQ declares its 24 connection parameters with it; its own list and check are gone, the behaviour is the same.
Other connectors with
connectionFactoryare not marked yet: marking one changes what a URI beside a factory does.
Changed — TLS options carry the names most connectors use
- Breaking. The client-certificate password is
sslCertPasswordeverywhere: RabbitMQ'ssslCertPassphrase(option, factory property andSslCertPassphrase) is renamed. Accepting any server certificate istrustAllCertificateseverywhere: AMQP'sSkipServerCertValidation(factory) and LDAP's and Mail'sskipCertificateValidation(option, factory, builder method.SkipCertificateValidation()→.TrustAllCertificates()) are renamed. The former names in a URI are refused with the new one named, not bound silently. CONCURRENCY.mdsays what an asynchronous step (InOnly.Threads(),seda:) does to a broker consumer'sackMode=manual: the route returns before the work is done, so the message is settled ahead of it.
Changed — VS Code extension 0.2.15: rabbitmq: options, enum values in any case, flags lists
- The editor catalog gets the
rabbitmq:options added in 5c1fb165 (publisherConnection,sslCaCertPath,sslProtocols,revocationMode,revocationSoftFail,authMechanism), taken from the generator run on the connector's build. Itsredb-route-catalog.jsonrecord also hadAutoAckand noAckMode(the ackMode change was written into the schema by hand only); it is regenerated. - Enum options in the catalog schema read the way the engine reads them. The option converter parses member
names in any case, and the engine's own messages write them lowercase (
ackMode=manual), but the schema took the exact C# names only, soackMode="manual"was an error in the editor. An enum option is now a union of the lowercase list (offered as completion) and a case-insensitive pattern. - A
[Flags]enum option (sslProtocols) takes several members joined by commas (Tls12,Tls13), as the engine does. The catalog labels itflags, the schema accepts the list, and the graph's property panel shows a text field instead of a pick-one list. - The renamed TLS options of 863bd447 are in the catalog:
sslCertPasswordonrabbitmq:(wassslCertPassphrase),trustAllCertificatesonamqp:,ldap:and the mail schemes. imap:,pop3:andsmtp:had no options in the catalog at all. The generator looked for the options class by the component's name or as its assembly's only one, and the three mail components shareMailEndpointOptions. It now reads it where the code declares it first: the component's endpoint derives fromEndpointBase<TOptions>(ImapEndpoint : EndpointBase<MailEndpointOptions>).check --binrefuses a connection parameter written beside the option that names a connection factory (abead3e8:[ConnectionParameter],[ConnectionFactoryReference]), URI form and structured form alike, with its position and the engine's wording. The catalog carries both declarations (connectionParameter,connectionFactoryReferenceinredb-route-catalog.json);rabbitmq:is the connector that declares them so far. The schema cannot express it: XSD 1.0 has no rule of one attribute depending on another.- The other connectors' schema elements get the enum and flags forms with the next regeneration from release bins (RELEASE_ARTIFACTS §5a).
Fixed — Kafka: exactly-once by cluster id; a commit of unknown outcome is not aborted (review 2026-09-28, §6.1, §6.4, §6.5)
- One cluster however it is listed. A consumer and a transactional producer were matched by their bootstrap lists
as written: one cluster reached through other brokers on the two ends counted as two, and the consumed offset stayed
out of the transaction (at-least-once). They are matched by the cluster id the brokers report now; the producer reads
it after
InitTransactions, the consumer on its first offer of offsets. - Commit and abort without a timeout of ours, as librdkafka strongly recommends (its API timeouts do not match the
protocol requests); they block up to
transaction.timeout.ms. A commit that keeps timing out has an unknown outcome: the producer no longer calls an abort that cannot undo it, it is rebuilt before the next transaction (whose initialization settles the previous one) and logs that a redelivery may write the records again. - A consumer losing its partition mid-transaction (rebalance,
maxPollIntervalMsexceeded) is now tested on the live cluster: the broker refuses its old generation, its output is aborted, and one record comes out.
Changed — VS Code extension 0.2.14: the editor knows as4: and the current as2: options
- The editor catalog (
redb-route-1.0.catalog.xsd,redb-route-catalog.json) had noas4element, andas2still offeredcertPassword(now refused) without the six options added since:asyncMdnAllowedHosts,allowLegacyAlgorithms,maxRequestBodySize,maxResponseBodySize,idempotentRepository,streamBody. Both are taken from the generator run on the connectors' builds at HEAD:as4with the sides of its options (receive-onlyport,idempotentRepository, …; send-onlypartner,action, …),as2as it is now. check --bin: the message about an option on the wrong side named the endpoint element in the structured form (<as4> creates the producer); it names the step now (<to> creates the producer).- Extension version 0.2.14: the schema and the catalog changed since 0.2.13 (bean lists and
ref=,whereRedb,outputType, strict connectors, option sides,http/log,<rest>,<constructorArg type>,as4).
Added — AS4 connector (redb.Route.As4): eDelivery AS4 1.16 over ebMS 3.0
- Schemes
as4andas4s. Send (As4.Send(...)): gzip, encrypt and sign a payload, POST it to the partner's access point, verify the receipt it answers with (signature and non-repudiation digests). Receive (As4.Receive(...)): decrypt, verify, decompress, match the message to an agreement, deliver it to the route, and answer with a signed receipt or an ebMS error after the route's unit of work. - One-Way/Push and Two-Way/Push-and-Push (the reply leg with
eb:RefToMessageId), both required by the profile. - The profile's cryptography only: RSA-SHA256, SHA-256, AES-128-GCM, RSA-OAEP with MGF1-SHA256, Exclusive C14N; the
three key references (
BinarySecurityToken,IssuerSerial,KeyIdentifier). Anything wider is refused at start, or answered with its ebMS code (EBMS:0101,0102,0103,0010, ...). As4ConnectionFactory(our node) andAs4Partner(one agreement per partner, pinned certificates, P-Mode-style matching on agreement, sender, service and action), validated when an endpoint starts.- Duplicate detection on receive (
idempotentRepository, required) and redelivery with the same message id and the same bytes on send, built fromIIdempotentRepositoryandOnException. - Hardening:
wsu:Timestampchecked by the WSS4J rules, duplicatewsu:Idrefused, required signature coverage, attachment MIME canonicalization (SwA 1.1), certificate validity with optional revocation, no exception text to the partner,maxRequestBodySize/maxResponseBodySize, TLS 1.2/1.3 and mutual TLS with allow-listed thumbprints. - The request and payloads are spooled through the core stream cache;
streamBodyhands a payload to the route as aStream. Shared Kestrel host, admission limits, stop drain, statistics, tracing,.Transacted(), Route-XML. - Interop tested live in both directions against Holodeck B2B 8.1.1 and Domibus 5.1 (Harmony AP 2.6.2). Pull and
asynchronous receipts are out of scope. See
src/redb.Route.As4/README.md.
Fixed — Kafka: sslEndpointIdentificationAlgorithm=none turns hostname verification off; TLS proven on a live cluster
nonemeanthttps. Only the empty string turned hostname verification off; any other value,noneincluded, turned it on, so a route asking to skip the check failed its handshake. The option now takeshttpsornone(empty still meansnone) and refuses anything else by name, on the endpoint and on the connection factory. DSL:.SslVerifyHostname(bool).- Password in clear.
saslMechanism=PlainoversecurityProtocol=SaslPlaintextlogs a warning that the password goes unencrypted. - DSL
.SslCert(string, string?, string?)next to the expression overload. - TLS, SASL_SSL with SCRAM-SHA-512, mutual TLS and a named factory carrying them are now tested against the 3-node cluster, with the refusals: another CA, a wrong password, a hostname the certificate lacks, no client certificate. README: a section on encryption and authentication.
Fixed — RabbitMQ: ssl=true is honoured on every connection path (security)
- A named connection factory never used TLS.
RabbitMQConnectionFactory.Build()set the TLS options and then assignedConnectionFactory.Endpoint, whose setter replaces them with the endpoint's (TLS off): withconnectionFactory=<name>andSsl = truethe client spoke plain AMQP, credentials included. A host list (host=a,b,con the URI, orHoston a factory) dropped TLS the same way: a connection over an endpoint list takes TLS from each endpoint, and the endpoints were built without it. Every endpoint now carries the TLS settings, and every connection is opened over the endpoint list. - The server name defaults to the host. Without
sslServerNamethe broker certificate was checked against an empty name and no SNI was sent, so the handshake could not pass. Each host is now checked against its own name;sslServerNamestill overrides it for every host. - A named factory's host list is its cluster. The hosts were taken from the endpoint URI (default
localhost), so a factory withHost = "r1,r2,r3"connected tor1only and never failed over. The factory's own list is used now. - One pooled connection is one TLS identity. The inline pool key now includes
sslServerNameandsslCertPath: two endpoints that differ only in the client certificate no longer share the first one's connection. - An empty host list fails with a message naming the option instead of connecting to
localhost.
Fixed — RabbitMQ: a transacted consumer's acks and nacks take effect (review 2026-09-28, R3, R4)
- On a transacted channel an ack or nack is itself transactional and takes effect only at
tx.commit. The consumer committed first and acked after, so each ack waited in the next transaction: the last delivery stayed unacknowledged and came back when the channel closed (a duplicate on every stop or restart). A failed delivery was rolled back and then nacked, so it was not requeued until the channel closed either. Every ack and nack is now followed bytx.commit; the nack is committed too, as Spring AMQP commits a transactional reject, sincetx.rollbackwould discard it.concurrentConsumersabove 1 needs no lock or restriction: each settle is final, and a commit that also carries a neighbour's settle leaves that neighbour's own commit an empty transaction. - The acknowledgement no longer poses as a deferred action of the route's
.Transacted()block: it had not been registered there since theackModerework, and theITransactedActionimplementation, itsRollbackand its guards were unreachable. The integration test that claimed to cover a double ack did not reproduce one and is removed; the new tests check the broker's side (no delivery left behind after stop, a failed delivery redelivered at once, twenty concurrent deliveries with failures settled exactly once).
Changed — RabbitMQ: an RPC reply keeps its bytes and its content type (review 2026-09-28, R1)
- Behaviour change:
exchange.Out.Bodyof areplyTo=truecall is nowbyte[], as the body of a consumed message always was; it was a UTF-8 decodedstring, which turned a binary reply (protobuf, PDF, gzip, a non-UTF-8 EDI file) into replacement characters. A route that read the reply as a string decodes it, or converts the body with.ConvertBody<string>(). The reply is mapped by the same code as a consumed message, soContentTypeand the other AMQP basic properties arrive as well; before, only the application headers did. - The replying consumer sends the reply with the reply's own
ContentTypeand headers when the route setexchange.Out; it used the request's type, so a JSON request answered with bytes went back labelled JSON.
Fixed — RabbitMQ: a message no queue takes fails the send on every path (review 2026-09-28, R2)
- The sends of a
.Transacted()block lost an unroutable message in silence. The block's batch is published on a transacted channel, which has no publisher confirms, so a mandatory message the broker returned (basic.return) left only a Warning in the log and the route went on as if it was delivered. The batch now collects the returns of its own commit and fails with the count: the messages that did route are committed, so a retry of the block sends those again. - Added
RabbitMQUnroutableException(exchange, routing key, reply code and text), thrown by every publish path, so oneOnException<RabbitMQUnroutableException>covers them all. An immediate send and a request-reply call already failed, with the client'sPublishReturnException; that is now theInnerException. - A request-reply call whose request comes back no longer retries on a new reply queue first: no queue takes the request, so no reply can come.
Fixed — RabbitMQ: option values checked at startup, and smaller fixes (review 2026-09-28, R5, R6, R8)
exchangeType,queueTypeandoverfloware checked when the endpoint is created. A value the broker does not know failed as a channel error at declare, in the broker's words; it now fails at once, naming the option and the allowed values.exchangeTypetakesdirect,topic,fanout,headersin any case (written in lower case, as the broker names them) or anx-plugin type such asx-delayed-message;queueTypetakesclassic,quorum,stream;overflowtakesdrop-head,reject-publish,reject-publish-dlx.ProcessedCountof a consumer counts every message withconcurrentConsumersabove 1; the unsynchronised increment could lose some.- A late reply to a direct reply-to client that has gone (
amq.rabbitmq.reply-to.*) is logged at Debug, as one to anamq.gen-*queue already was, instead of as a Warning about an unrouted message.
Changed — RabbitMQ: a named connection factory is the whole connection (review 2026-09-28, R7)
- Behaviour change: a URI that names
connectionFactoryand also gives a connection parameter is refused when the endpoint is created (host,port,username,password,virtualHost,clientName,ssl*, the timeouts,heartbeat, the recovery options,consumerDispatchConcurrency). The factory already set all of them and the URI value was ignored in silence, soconnectionFactory=prod&host=otherwent toprod. The rule stays Apache Camel's ("all connection options set on URI are not used"); only the silence goes. - Endpoints without a factory share a pooled connection only when every connection setting matches. The first endpoint no longer decides the heartbeat, timeouts, recovery policy, client name or password of the others. The password enters the pool key as a short hash, since the key is logged.
Added — RabbitMQ: publisherConnection (review 2026-09-28)
publisherConnection=truesends an endpoint's publishing (its producer's sends and its consumer's RPC replies) over a second connection with the same settings, named"<clientName> (publisher)". The broker's flow control and resource alarms block a publishing connection as a whole, and a consumer sharing it could not ack. Off by default, as in Spring AMQP: producers and consumers keep sharing one connection, and two named factories separate them too.
Added — RabbitMQ: private CA, TLS versions, revocation, EXTERNAL and OAuth 2.0 logins
sslCaCertPath(factory:SslCaCertPathorSslCaCertificates): the broker certificate must chain to these roots instead of the system trust store, for a private or corporate CA; the server name is still checked.ClientCertificateon a factory: theX509Certificate2itself (Windows store, key vault) for mutual TLS. A client certificate, from a file or as an object, must have its private key and be inside its validity period; both are checked before the handshake, naming the certificate.sslProtocols(Tls12,Tls13); SSL 3.0, TLS 1.0 and 1.1 are refused.revocationModeandrevocationSoftFailcheck the broker certificate against CRL/OCSP; a chain that names no revocation source passes, as in the AS4 connector.authMechanism=External: the client certificate is the login (SASL EXTERNAL), no password is sent.- OAuth 2.0 on a factory (
OAuth2TokenEndpoint,OAuth2ClientId,OAuth2ClientSecret,OAuth2Scope): the connection logs in with a client-credentials access token and renews it on the open connection before it expires, so the broker does not close it. The token endpoint must be https (http only on the loopback address). Any other source of credentials plugs in asCredentialsProvider. New dependency:RabbitMQ.Client.OAuth22.0.0. - A TLS setting without
ssl=trueis refused instead of being ignored, and so are contradicting settings (a passphrase without a certificate, a certificate given twice, soft-fail without a revocation check, two ways to log in, EXTERNAL without a client certificate). - DSL:
.SslCaCertPath(),.SslProtocols(),.RevocationMode(),.AuthMechanism(). - Tested against a broker with a private CA, mutual TLS, EXTERNAL and OAuth 2.0 (60-second tokens, the connection outlives the first one only through the renewal).
Fixed — RabbitMQ: a channel the broker closed (review 2026-09-28, R9, R10)
- A producer recovers from a channel-level error. A publish to an exchange that does not exist (404) closes the
channel but not the connection, and every later send failed with "The producer might need restart". The next send
now opens a new channel (and a new reply queue for
replyTo=true); only the send that hit the error fails. - A consumer that stops receiving says so. When the broker closes its channel, or cancels its subscription because the queue was deleted, it logs an Error naming the queue; it went quiet before.
- A delivery waiting for a free consumer slot is counted as in flight, so
Stopdrains it too rather than letting it start once the drain has ended.
Fixed — Kafka: a transactional producer stopped during its rebuild stays stopped (review 2026-09-28, R9)
- After a fatal transaction error the producer rebuilds its client before the next transaction. The rebuild went round
Start: a producer stopped in the meantime got a new client that nobody closed, and committed the transaction as if it were running. It now closes that client and fails the transaction as a stopped producer does.
Changed — Kafka: a record passed on keeps its key; guarantee-breaking librdkafka properties are refused (review 2026-09-28)
- Behaviour change: the consumed key is carried over. A producer without the
keyoption now sends withredbKafka.Key(the key of the record the route consumed), as camel-kafka and Spring Kafka do. A route passing records on (Kafka to Kafka, retry, dead letter) used to send them without a key: they changed partition, lost per-key order and compaction. New optionkeyFromHeader(defaulttrue; DSL.KeyFromHeader(false)) restores sending without a key;keyandpartitionNumberkeep precedence. - additionalProperties.
enable.auto.commit=trueis refused (a timer committed records read but not yet processed, whateverackModesaid), as areenable.idempotenceorackscontradictingtransacted=trueortransactionalIdPrefix. The rule, and the existing refusal oftransactional.id, now covers the named connection factory'sadditionalPropertiestoo; before, the factory was a way round it. Values that agree with the connector (enable.auto.commit=false,acks=all) are left alone. - One
content-type. A record passed on is sent with onecontent-typeheader,Message.ContentType's; the consumed header was copied next to it.
Fixed — Kafka: exactly-once no longer degrades in silence, one message shape in every mode (review 2026-09-28)
- Offsets left out of a transaction are reported. When a transactional producer cannot take the consumed offsets,
because the consumer's brokers are written differently (another cluster by the connector's reckoning) or because the
route already returned to the consumer (an asynchronous step inside
.Transacted()), the producer logs a warning with the cause, once per producer, then at Debug. Before, only a Debug line told that the route was not exactly-once. - Batch mode. A record read with
maxPollRecordsnow carriesredbKafka.KeyandredbKafka.Timestamp, as in single mode; a route reading the key gotnullin batch mode. - Deferred send metadata. A send deferred to
.Transacted()withrecordMetadata=truenow setsredbKafka.Sent.Timestamp, as an immediate send does. - Idempotence against the factory's acks.
enableIdempotence=trueon the endpoint withacksother thanallfrom the connection factory is refused in the connector's words at start, instead of by librdkafka. - Docs.
seekToacceptsbeginningorendonly; theacksdefault isAll; the README states when the consumed offset rides in the transaction and which headers go on the wire.
Fixed — AS2: the receiver authenticates before it acts, and an MDN confirms only what it proves (review 2026-09-28)
- Receipt URL (security). An asynchronous MDN is posted to the sender's
Receipt-Delivery-Optiononly when the message authenticated, the agreement'sMdnModeisAsync, and the host is in the newAs2ConnectionFactory.AsyncMdnAllowedHosts(required for an async receive endpoint). Before, any POST with that header, even with a forged signature, made the receiver post a receipt signed by our key to a loopback or private address of the caller's choosing. The receipt is posted after the200, outside the request, bounded bytimeout. - Identifiers.
AS2-FromandAS2-Tomust be the agreement's (RFC 4130 §6.2); otherwise the message is refused withauthentication-failedand does not reach the route. - MIC. An MDN without
Received-Content-MIC, or for a message nobody waits for, is no longer a match. New headersredbAs2.mdnMicStatus(matched,mismatch,absent,unknown) andredbAs2.mdnConfirmed(positive, MIC matched, signature valid where a signed MDN is agreed);RequireValidMdnfails the send on any of them. MIC digests are compared as bytes. - Asynchronous MDN policy. An unsigned or foreign-signed receipt where
SignedMdnis agreed is not a confirmation and does not end the wait; withRequireValidMdnit is refused with400. - Agreement checked at start.
As2ConnectionFactory.Validate(name)(identifiers, algorithms, certificates and private key, URLs) runs when each endpoint starts. With a factory named, agreement options on the URI are refused.sha-1and3desneed the newAllowLegacyAlgorithms. Pinned certificates must be within their validity period. - Protocol. An MDN only when
Disposition-Notification-Toasks for one, signed only when asked, with the requestedsigned-receipt-micalg; the producer asksrequiredwhenSignedMdn. A refused message's MDN carries the RFC 4130 §7.4.3 code and a reference instead of our exception text. Dispositions are read by their fields. - Limits and lifecycle. New
maxRequestBodySize(receive, 100 MB, 413 above) andmaxResponseBodySize(send, 4 MB). The MDN receiver drains on stop, takes the admission limit, opens aConsumerspan, and labels its exchanges withremoteAddressandpartner. The producer disposes its response. - Headers.
Authorization,Proxy-AuthorizationandCookieof the inbound hop are not put on the exchange;Authorization,CookieandSet-Cookieare not bridged onto an outgoing message.
Fixed — AS2: interop with OpenAS2 on asynchronous MDN, and the MIC of a received message
- The receiver hashed a received signed part after preparing it for 7bit, so a partner that signs a
binarypart (OpenAS2) got a MIC it rejects. The MIC is now over the part as received (RFC 4130 §7.3.1). OpenAS2 checks the MIC only on asynchronous MDNs, which is why synchronous interop did not show it. - The machine-readable MDN part could go out quoted-printable when a field line was long; RFC 3798 requires 7bit, and OpenAS2 then cut the Original-Message-ID and could not correlate an asynchronous MDN.
- The producer registered the wait for an asynchronous MDN after the partner's 200; a partner that posts the MDN first (OpenAS2 often does) was read as an MDN for an unknown message. The wait is registered before the POST and dropped when the send fails.
Added — AS2: the received message is spooled; streamBody
- The request, each decrypted and decompressed stage and the payload go through the core stream cache (a temporary
file past its threshold), as in the AS4 receiver, instead of three or more copies in memory. Before, parallel
unauthenticated POSTs up to
maxRequestBodySizeeach could exhaust the heap. The MIC is hashed as it is written. streamBody(receive): the exchange body is the spooled payload as aStream, closed with the exchange.
Added — AS2: duplicate detection
idempotentRepositoryon a receive endpoint: aMessage-IDprocessed before is answered withprocessed/warning: duplicate-documentand not delivered again; the claim is taken after authentication and released when the route fails.
Removed — AS2: certPassword
- The URI option did nothing (certificates are on the connection factory). A URI that sets it is now refused, saying where certificates go.
Fixed — Route-XML <bean>: constructors and factory methods are chosen the way C# chooses them; type= names an overload
- A certificate could not be loaded from a PFX in XML on .NET 8.
X509CertificateLoaderexists from .NET 9, andX509Certificate2(string, string)is one of several two-argument constructors, so the choice failed with "Multiple constructors accepting all given argument types". Reported by the AS4 agent; it concerns every connection factory holding a certificate (AS2, Soap, AS4). - The documented
X509CertificateLoader.LoadPkcs12FromFileexample never worked either: the method has two more, optional, parameters, and a factory method was looked up by the exact number of arguments. - Constructors and factory methods are now called the way C# calls them. The parameters after the given
arguments must be optional and take their defaults, including enum defaults. An overload no markup value can
reach (a
ReadOnlySpan<char>orrefparameter) is not a candidate. <constructorArg type="System.String">, like Spring'sconstructor-arg type, names the parameter types; the constructor or factory method is then the one with exactly that signature, and each value is converted to its type.type=goes on every argument or on none.- Several factory-method overloads of one arity used to be resolved by taking the first. That is now a load error
listing the candidates.
check --binapplies the same rule and checks each value against the chosen parameter. - XSD, the VS Code schemas, the generator (
XmlBeans.Create(…, argumentTypes: [...])), README and the format spec.
Added — Route-XML: an option on the side of the endpoint that does not read it is caught in the editor and by check --bin
[EndpointRole](9a47caec) declares options only one side reads:http:producers sendauthScheme,username,password,authToken;http:consumers requireinboundAuth,inboundUsername,inboundPassword,inboundRealm,tokenValidator. The connector refuses such an option on the other side, but only when the route starts.- The catalog carries the side (
CatalogOption.Role,"role"inredb-route-catalog.json, read throughEndpointOptions.RoleOf). The catalog schema generates the endpoint elements twice: inside<from>the consumer's view, inside the other address-carrying steps the producer's (pollEnrichincluded: the engine polls through a producer). A strict connector does not declare the other side's options; a lenient one (http:) declares them with a type no value satisfies, so the editor's message names it (producerOnlyOption,consumerOnlyOption). check --bin/pack --binreport an option on the wrong side, URI form and structured form alike, with its position:'username' is read only by the producer side of the http endpoint; <from> creates the consumer, which refuses it.- VS Code catalog schema and
redb-route-catalog.json:httpandhttpsregenerated with the sides.
Added — [EndpointRole]: an option only one side reads says so on its property
[EndpointRole(EndpointRole.Consumer)]/[EndpointRole(EndpointRole.Producer)]on an options property, Camel's@UriParam(label = "consumer"/"producer").EndpointOptions.RoleOf(property)reads it by reflection, without an endpoint, for the Route-XML catalog, the editor schema andcheck;EndpointOptions.WrittenForOtherRole(...)gives the parameters a URI wrote for the other side, as written.- The http: endpoint declares its nine side-only options with it (
authScheme,username,password,authTokenfor the producer;inboundAuth,inboundUsername,inboundPassword,inboundRealm,tokenValidatorfor the consumer) and refuses them on the other side by the declaration instead of two hand-kept lists of names. Behaviour and messages are unchanged.
Added — Route-XML: the package gate knows repository names and types
<redb><idempotentRepository name>now counts as a declaration of that name, like<bean name>. Before, the gate warned thatrepository="#dedup"named nothing although the repository was declared (1782f31b made the bare name the registry key).- With
--binthe gate checks that the object a repository reference names implements what the reference needs:IIdempotentRepositoryfor<idempotentConsumer repository>,IClaimCheckRepositoryfor<claimCheck repository>. A bean of another type is an error with its position, worded like the engine's refusal ('dedup' is registered as …, which is not an IIdempotentRepository). - Contributions declare this themselves:
AttributeSpec.References(the type a reference must name) andAttributeSpec.Registers(the attribute names a registry entry of that type), init properties next toResource. The gate knows no package element by name.
Fixed — Route-XML tooling: http: and log: were missing from the component catalog
redb-route-xml catalogcreated only components with a parameterless constructor.HttpComponent(string scheme = "http")and the log component have optional parameters only, so both were skipped: the editor flagged every structured<http>endpoint and offered nohttp:options. Constructors with only optional parameters are now used, the way the engine calls them.- VS Code catalog schema and
redb-route-catalog.json:httpandlogadded,httpsrefreshed (the inbound authentication options),<rest>refreshed from the generator (clientRequestValidation,errorHandler,<param>, the inbound authentication attributes). The<rest>edits had gone into the leanredb-route-1.0.xsd, which the editor does not read. The hand-madeackModeedits were checked against the generator output from the connectors' own builds and match byte for byte.
Changed — idempotent and claim-check repositories are found by their bare name
- Breaking for a repository registered by hand under
idempotent:{name}orclaimcheck:{name}(AddToRegistry("idempotent:orders", …)): it is no longer found. Everywhere else#nameis the registry key as is (dataSource=#main-db,bean:#x,routePolicy,ref=); these two lookups were the exceptions, so a bean declared in markup underdedupwas not the repository<idempotentConsumer repository="#dedup">or an AS4 consumer'sidempotentRepository=deduplooked for, and the context did not start. - The providers look up the bare name, checked for type, as Camel's
lookupByNameAndType, and tell the two failures apart: nothing registered under the name, or an object of another type there ('dedup' is registered as …, which is not an IIdempotentRepository). The messages point toAddIdempotentRepository(name, repo)/AddClaimCheckRepository(name, repo)or<bean name="…" type="…"/>, not to a prefixed key.IIdempotentRepositoryProvider.TryGetis false for nothing and throws for another type. AddIdempotentRepository,AddClaimCheckRepositoryandAddRedbIdempotentRepositoryregister under the bare name; their signatures are unchanged.RegistryIdempotentRepositoryProvider.KeyPrefix,ClaimCheckRepositoryRegistry.KeyPrefixandClaimCheckRepositoryRegistry.DefaultKeyare removed.- The claim-check repository of steps that name none is the context's
IClaimCheckRepositoryservice (SetDefaultClaimCheckRepository, or one the host registered), else one shared in-memory repository kept as that service — noclaimcheck:__defaultregistry key.
Added — IRouteContext.GetStreamCacheOptions() for connectors that spool
- The stream cache options in force for a context, resolved as the
.StreamCaching()step resolves them: a registeredStreamCacheOptionsservice, else the engine'sRouteEngineOptions.StreamCaching, else the defaults. A connector spooling throughStreamCache(AS4) could reach only the registered service, so a threshold and a directory set on the engine never reached it. The engine-wide caching switch takes its threshold and directory through the same chain.
Changed — StreamCache is public for connectors; stream caching closes what it replaces and obeys the engine switch
StreamCache(memory up to a threshold, then a temporary file withDeleteOnClose) is public: a connector spools what it must not hand to the route before it is complete and checked — AS4 decrypts an attachment into one and passes the stream on only after the tag and the signature verified. It is written through (Write,WriteAsync,CacheFromSourceAsync), thenCompleteWriting()rewinds it and makes it read-only and seekable; reading before or writing after that throwsNotSupportedException. Built fromStreamCacheOptionsor a threshold and a directory.- The
.StreamCaching()step closes the stream it replaces. It used to leave it open: the exchange disposes only what the body is, so a streamed file (streamBody=true) stayed open until the garbage collector ran. RouteEngineOptions.StreamCaching.Enabledworks: every route caches a stream body before its first step (Camel's context-widestreamCaching). It was never read..StreamCaching()without its own threshold takes the engine's options.- The endpoint byte counter measures a stream body by its length, or counts nothing for a forward-only one, instead of serializing the stream object.
Security — an http: consumer drops response-only headers from a request
Set-Cookie,Location,WWW-Authenticate,Proxy-Authenticate,Authentication-Info,Proxy-Authentication-Info,Retry-After,Server,Age,ETag,Accept-RangesandVaryin an inbound request are dropped at the transport, before the exchange is built (HttpHeaders.ResponseOnlyHeaders): neither the route nor the response ever holds a value a client planted under a response field's name. Stricter than Camel's inbound filter, which removes only its own headers. The value comparison of the previous entry stays as the second layer, and the connector README states the contract: a processor that builds a response header from a request value answers for it.
Fixed — every credential on a connection factory is marked [Sensitive]
LdapConnectionFactory.BindPasswordand 34 more secrets on 21 connection factories (passwords, passphrases, API keys, access and session tokens, connection strings) lacked the declaration the renderers mask by; SOAP, AS4 and Kafka had it. A test over the factories' sources keeps new ones from slipping through.
Security — a client can no longer suppress a response header the route wrote
- An
http:consumer does not echo the request's headers back, and it matched them by name: a request carryingCache-Controlremoved the route'sCache-Control: no-storefrom the response, so a token response became cacheable at the client's say. Any header the route wrote under a name the client also sent was dropped the same way. Only a value that is still the one the client sent is held back now; what the route wrote goes out.
Added — inbound authentication on http: consumers and Rest(...): Basic and Bearer
inboundAuth=basicchecksAuthorizationagainstinboundUsername/inboundPassword(both halves compared in constant time);inboundAuth=bearerhands the token to a registeredIHttpTokenValidatornamed bytokenValidator=#name. A refused request gets 401 withWWW-Authenticate(Basic realm="…", charset="UTF-8", orBearer realm="…"witherror="invalid_token"when the validator refused it) and never reaches the route; a validator's principal whose identity is not authenticated counts as refused. An accepted request reaches the route with the principal on the exchange (ExchangePrincipal) and without theAuthorizationheader.- The connector does not read tokens: JWT, signing keys, scopes and roles belong to the identity provider's library,
plugged in through
IHttpTokenValidator. Written in the connector README as a boundary. - A half-declared check stops the start: credentials or a validator without
inboundAuth,basicwithout a password,bearerwithout a validator, an unregistered validator name, a realm with quotes. The inbound options on a producer are refused, as the producer'sauthScheme/username/password/authTokenalready are on a consumer. Rest(...)takes the same options for every route of the declaration, the OpenAPI document included; Route-XML<rest>takes them as attributes, and the editor schema knows them. The C# generated from<rest>now carries theusingdirectives it needs (redb.Route.Http,redb.Route.Http.Rest), which it lacked.
Fixed — a sql: step that would write past the route transaction is refused
- On SQLite a
sql:statement, procedure or batch inside.Transacted()ran in autocommit: Microsoft.Data.Sqlite does not enlist in System.Transactions, so a block that rolled back kept the step's rows, while the README promised enlistment. The step now refuses before it writes when its connection stays outside the ambient transaction: Microsoft.Data.Sqlite, Firebird withoutEnlist=true(its client enlists only when asked), and any provider withEnlist=falseorAutoEnlist=false. PostgreSQL and SQL Server were checked live: twosql:steps in one block share the transaction, as before.
Changed — one ackMode on every broker consumer
- Breaking. When a consumer settles what it received is one option with one meaning,
ackMode=manual|auto, on RabbitMQ, AMQP 1.0, Kafka, SQS, Azure Service Bus and Redis Streams.manual(the default) settles after the route by its outcome: acknowledged when the exchange ended well, released for redelivery when it failed (at-least-once).autosettles on receipt, before the route runs (at-most-once): RabbitMQ auto-ack, Service Bus ReceiveAndDelete, RedisNOACK, and for AMQP, Kafka and SQS the consumer accepts, commits or deletes right away. The default behaviour of every consumer is unchanged. - It replaces
autoAck,autoAccept,enableAutoCommit,deleteAfterRead(SQS),receiveMode(Service Bus) andstreamNoAck, in URIs, in the fluent builders (.AckMode(AckMode.Auto)) and in the editor catalog. The old names are refused with their replacement named.autoAccept=false,enableAutoCommit=falseanddeleteAfterRead=falsesettled nothing at all (no API let the application do it), so every message came back or no offset ever moved; they have no successor. - Contradictions are refused when the endpoint is created:
ackMode=autowith RabbitMQtransacted, with KafkabreakOnFirstError, with SQSextendMessageVisibilityorresetVisibilityOnFailure, with RedisstreamClaimMinIdleMs. TRANSACTIONS.mdhas the table of what each broker does in each mode.
Added — Route-XML: a strict connector's unknown parameter is caught in the editor and by check --bin, not at start
- A connector not marked
[LenientProperties]refuses a parameter it has no option for when the endpoint is created. In XML that surfaced only when the route started: the catalog schema let any attribute through on every connector, and the package gate did not look at endpoint parameters. - The catalog carries the engine's flag (
CatalogComponent.Lenient,"lenient"inredb-route-catalog.json, read throughEndpointOptions.IsLenient). In the catalog XSD a strict connector's element takes no unknown unqualified attribute; a lenient one (http:,sql:,bean:) takes any, as before. check --bin/pack --binbuild the catalog from the same output and hold every endpoint's parameters against it, URI form and structured form alike. An unknown name on a strict connector is an error with its position, worded by the engine itself ('perod' is not an option of the Timer endpoint. Did you mean 'period'?). Names built at run time (${…},{{…}}) andinterceptFrompatterns are left to the engine.
Fixed — Route-XML: a foreign-namespace attribute on a structured endpoint is no longer a parameter
- The format tolerates attributes in other namespaces as metadata for other tools, but the structured form
passed them to the connector by their local name:
<timer ui:xy="10,20"/>becamexy=10,20, which a strict connector refuses when the route starts. They are now skipped, in the loader and in the C# generator. The loader, the generator and the gate read a structured endpoint's options through one function. - The VS Code catalog schema gets the strict connectors with the next regeneration from release bins (RELEASE_ARTIFACTS §5a); until then the editor keeps accepting unknown attributes on them.
Fixed — ConsumerTemplate no longer loses what it does not hand out; DoneUoW as in Camel
- Data loss. A
Receivefrom a polled directory withdelete=truehanded over one file and deleted all of them: everyReceivestarted a throwaway consumer whose processor returned at once, so the consumer committed each exchange of its poll (deleted or moved the file, acknowledged the message) whether or not anyone received it, and released the handed-out exchange before the caller read it. - The template now keeps one consumer per endpoint, started on first use and stopped with the template. Each
exchange waits in a queue for a
Receive; the source commits it only when the caller calls the newDoneUoW(exchange)(Camel'sdoneUoW), and rolls it back when the caller setexchange.Exceptionfirst. Exchanges nobody received stay uncommitted in their source.ReceiveBody*completes the unit of work itself, reading a streamed body into memory first. A template stopped with units of work still open rolls them back: the file stays, the message is redelivered. - Breaking for a caller of
Receiveon a non-SEDA endpoint: it must callDoneUoW, otherwise the source keeps the exchange until the template stops. SEDA exchanges acceptDoneUoWtoo, so one loop serves any endpoint.
Fixed — VS Code editor: the schema it reads knows <bean> lists and references and the new <redbQuery> attributes
- The extension validates markup against
redb-route-1.0.catalog.xsd(bound inmedia/catalog.xml). The schema edits for<bean>lists andref=, and forwhereRedb,orderByRedbandoutputTypeof<redbQuery>, went into the leanredb-route-1.0.xsd, which the editor does not read, so the editor flagged valid markup and offered no completion for it. Both sections are now taken from the generator into the bound schema. - Route-XML README:
in/not inin conditions and in<redbQuery>, with an example covered by a test. RELEASE_ARTIFACTS.md§5a named the wrong schema as the one the editor reads, and ranxsdwithout bins, which drops the package elements from the lean schema. Both corrected.
Changed — endpoint parameters: a bad value or an unknown name stops the endpoint, for every connector
- Breaking for a route whose URI carries a mistake, and every such route ran with an option other than
the one written. A known option whose value does not convert to its type (
recursive=yes,maxDepth=abc,mode=pul, a number that names no enum member,${...}in an option that takes no expressions) used to land among the unmapped parameters and leave the option at its default without a word. It is now refused when the endpoint is created, inEndpointOptions.BindFromUri, for all connectors at once. The message names the value and what the option takes, enum values spelled as the URI takes them (mode=push, mode=poll); the value of a[Sensitive]option is not shown. - An unknown parameter name is refused the same way unless the options type is marked
[LenientProperties], Camel'slenientProperties(strict unless declared). The message names each unknown parameter, by name only, with the nearest option.http:,sql:andbean:are lenient: they read the extra parameters themselves (param.*, bean properties). The mark is an attribute on the options type, so tooling reads it without creating an endpoint (EndpointOptions.IsLenient). - A connector adds its own words through
OptionValueHint(a refused value) andUnknownParameterHint(a refused name). The copies of this check inkafka:andredis:are gone;redis:keeps its explanation of the removedstreamAutoAckthrough the hook. Thesql:check of unconvertible values is gone too, the core one covers it. double,floatanddecimaloptions no longer accept a thousands separator:ratio=1,5was read as 15, it is now refused.
Added — REST request validation: 406, declared parameters, error handler
Param(name, type, required, dataType, description)on a REST verb declares a path, query or header parameter;<param>does the same under a verb in Route-XML. Declared parameters always go to the OpenAPI document with their location, requirement, type and description.RestOptions.ClientRequestValidation(Camel'sclientRequestValidation), overridable per verb withClientRequestValidation(bool)/clientRequestValidation=, enforces them: anAcceptthat excludesProducesis answered with 406 (RFC 9110 ranges and weights, the most specific range decides,q=0refuses, no header admits everything); a missing required parameter or a value that does not convert to itsdataType(integer,number,boolean) with 400 naming the parameter. Off by default, and switched on only by the option, never by the presence ofParam: a route that described its parameters for the document must not start refusing requests after an upgrade. The 415 check ofConsumesis unchanged and runs either way.- A path parameter must be a segment of the template and cannot be optional; a parameter declared twice is refused at declaration.
RestOptions.ErrorHandler(errorHandler="#name") names a registeredIProcessorthat writes the body of a 415, 406 or 400. It finds code, reason and parameter inRestErrorProperties. A name that is not registered stops the start. Without it the body stays the reason astext/plain.- The editor schema (
redb-route-1.0.xsd) knows the new attributes and<param>.
Added — in and not in in the route language and in redbQuery
- Membership of a value in a list, as Camel Simple spells it:
header.tier in ('gold','silver'),header.code not in header.codes. Works wherever a condition does —filter,choice/when,onWhen, intercepts. Asked for 2026-09-25 so markup can search by a list of keys in one step. - The right side is a collection the message carries (
header.codes,body.ids) or a list literal in parentheses (('a','b'),(1, 2, 3),()); parentheses afterinalways mean a literal. Camel's comma string (in 'gold,silver') is not adopted: a comma inside a string already means something else inincludeandantInclude, and a string on the right is refused with both forms named rather than iterated character by character. Elements compare with the same equality==uses, soincannot disagree with==about2and2L. A missing collection is an empty one.in(without a space is the operator, not a call to a function namedin;header.inis still a header called "in". redbQuerytranslatesmember in listintoEnumerable.Containsover an array typed as the member, which the storage turns into an SQLINfor props (where) and base fields (whereRedb) alike. Each element converts to the member's type exactly as a single compared value does. An empty list is decided in the route — nothing found forin, everything fornot in— and never reaches the storage as an emptyIN, whose meaning is settled in SQL the route cannot see. A member on the right ofinis refused when the route is built. Verified live on PostgreSQL and SQL Server.
Security — an http: consumer refuses credentials it would not check
authScheme,username,passwordandauthTokenare what anhttp:producer sends. They live in the options class the consumer shares, sofrom("http://0.0.0.0:8080/api?authScheme=basic&username=admin&password=…")bound without a word, passed validation — which even demanded a non-empty user and password — and started an endpoint that checked nothing. Its author believed it was closed, and the password sat in the route key besides. Found while planning inbound authentication, 2026-09-25.- Creating such a consumer now fails, naming the parameters and never their values. Only what the consumer's own URI wrote counts: a named connection factory may carry producer credentials beside the TLS material a consumer reuses it for, and that says nothing about inbound access. Producers are unchanged.
- Breaking for a route that set these on a consumer — and every such route was unprotected, so the break
is the point. Put inbound authentication in front of the route (a reverse proxy, or a processor that checks
Authorization) until the connector offers its own.
Fixed — the SSE summary event carries numbers as numbers, in any locale
- The
event: donetrailer anhttp:consumer writes after a streamed reply turned every value into a string through a culture-dependentToString(): a token count went out as"7"rather than7, and on a Russian-locale workerllm.cost.usdwent out as"0,0123"— a number rendered with a comma, inside quotes. The Llm documentation shows plain numbers, so the contract and the code had already parted. Reported 2026-09-25. - Numbers and booleans are now written as JSON numbers and literals; anything else as invariant text. A client that parsed the old strings as strings needs to read numbers instead — the documented shape was always numbers.
Fixed — a cached prompt reaches the usage of an OpenAI-compatible provider
OpenAiProviderread onlyprompt_tokensandcompletion_tokens, so a prefix the provider served from its cache never reachedLlmUsage.CacheReadInputTokens, thellm.tokens.cache.readheader, or an audit and a cost calculator built on them. With DeepSeek, which caches a repeated prefix by itself, every turn looked like a full-price prompt (issue #11). The cached part is now read — OpenAI'sprompt_tokens_details.cached_tokens, DeepSeek'sprompt_cache_hit_tokens, whichever the provider fills — and comes out ofInputTokens, which is the remainder billed at full price, not the whole prompt (DeepSeek names that remainder itself inprompt_cache_miss_tokens). A token budget and a cost estimate therefore stop paying for a cached prefix twice.- The streamed path reports the same numbers: it hands the terminal usage frame to the same parser.
CacheCreationInputTokensstays zero on this path: DeepSeek does not bill cache creation separately and OpenAI's cache is implicit. Anthropic, which reports both sides, is unchanged.- The stored conversation keeps the cache counters too, for every provider:
MessagePropsgainsCacheCreationInputTokensandCacheReadInputTokens, and a turn read back fromRedbConversationStorecarries its whole usage. They used to be dropped on write, Anthropic's included. Turns stored before read them as zero.
Added — RedbQuery / <redbQuery> returns one object, a count or a yes/no
- The query always returned a list, so a lookup by key read
body[0]and an existence check loaded rows to test them for emptiness. - New
outputType:List(default,ToListAsync),First(FirstOrDefaultAsync, the object ornull),Count(CountAsync),Any(AnyAsync). It exists in C# (RedbQueryOutput) and as an attribute of<redbQuery>, and the generator prints it. Firsttakes ordering andskip, nottake.CountandAnyrefuse ordering,takeandskip. The unbounded-scan guard now applies toListonly, the one output that loads every matching row.
Added — RedbQuery / <redbQuery> filter and order on the base fields of the stored object
- A route query could filter only on props. The base fields of the object table (
Id,ParentId,ValueGuid,ValueString,DateCreateand the rest) were out of reach, although they are indexed columns and the condition that cuts the most rows. Code used redbWhereRedbin a bean instead, and the markup had nothing to offer. - New
whereRedbcondition, the same as redbWhereRedb, andorderByRedb, the same asOrderByRedb. They exist asRedbQuery(whereRedb:, orderByRedb:)in C# and as attributes of<redbQuery>in XML, and the generator prints them.whereRedbcombines withwhereby AND, because the storage cannot mix base fields and props inside one OR. A query orders byorderByor byorderByRedb, not both. - Only the fields the core maps to columns are accepted. The core maps an unknown name to
_idwithout a word, so a computed member such asHasParent, a props name, or a nested path is refused when the route is built.ValueBytesis left out: it has no comparison to express. - The condition translator now serves both roots, props and base fields. Text dates convert to
DateTimeOffsetmembers, and the refusal names the attribute (whereorwhereRedb) and the candidates.
Added — Route-XML <bean> holds lists and references to other beans
- A bean could not list other beans, so a node with its partners (each partner its own registered bean, a second certificate slot for rotation) could not be declared in XML. The AS4 agent reported it on 2026-09-25 and worked around it with a string of names.
<property>and<constructorArg>now takeref="name"or a<list>. A list holds<value>,<ref bean>, anonymous<bean>and nested<list>items. It builds into the declared type (T[],List<T>,IList<T>,ICollection<T>,IReadOnlyList<T>,IReadOnlyCollection<T>,IEnumerable<T>), and each item is checked againstT. A list passed to a constructor names its element type withof=.- A reference may only point at a bean declared earlier in load order, or at one registered by
module code. A forward or unknown reference is a positioned error, never a
null. - One grammar (
BeanModel) and one builder (BeanFactory) now serve the loader, the generated C# (XmlBeans.Ref,XmlBeans.List) and thecheckgate. The gate reports forward references across files. With--binit also checks list items, nested beans and the type afactoryMethodreturns. Before this, nested beans went unchecked, and a factory bean was checked as its declaring type. - Generated
XmlBeans.Createnow resolves{{key}}in constructor arguments too. Before, it did so only in properties. - XSD and the VS Code extension schema:
ref=on the slots and thebeanListtype.
Fixed — stopping an HTTP-hosted route waits for the requests it is already serving
http,soap,as2andgrpcunregistered the route and asked the shared listener to stop when empty. When another route still holds the port the listener stays up, so nothing waited: a deploy that removes one route cut its in-flight requests mid-pipeline while the port kept serving. Reported by the AS4 agent 2026-09-25, who hit it while planning the same lifecycle for ebMS.- All four now count requests through the engine's
InflightDrainGuardand drain inStop()after unregistering — new requests can no longer reach the route, the ones inside it finish. This is the guard every broker consumer already uses, and the one SignalR and WebSocket were already using; the HTTP family was the gap. - The drain is bounded (30 seconds by default) and force-cancels what is left, so a hung handler cannot hold a deploy forever.
4.1.1
This release carries a security fix. A document type declaration in somebody else's XML could expand a tiny message into a huge one in memory, a denial of service against every route in the process. Every reader of foreign XML now refuses a DTD. The entry marked Security below says what was possible and what changed.
Security — XML from somebody else is read with a DTD refused, not expanded
- A document type declaration can nest internal entities so that a tiny message expands into a huge one in memory — the "billion laughs" shape of an XXE attack, and a denial of service against every route in the process. Measured on .NET 10: 452 bytes became 300 000 characters, and the real thing does not stop at five levels. Reported while preparing the AS4 connector, 2026-09-25.
- The direct loaders all expand it:
XmlDocument.Load,XDocument.LoadandXDocument.Parsealike — LINQ to XML is no safer than the old DOM here — andXmlResolver = nulldoes not help, because the expansion runs on internal entities rather than external ones. Only a reader built byXmlReader.Createrefuses a DTD, which it does by default. - New
SafeXmlin the core reads every document through such a reader, with an optional size bound, and keeps the two properties call sites depend on: line info for positioned errors, and preserved whitespace for signature canonicalization. Everything that reads somebody else's XML now goes through it: the SOAP envelope parser, the SOAP consumer and producer (decryption, signature checks, body fragments, WSDL rewriting),xpath()over a message body — which is any transport, not just SOAP — the XML model of a payload template, and the markup loaders, whose files arrive inside a package. - A DTD is refused rather than budgeted: SOAP forbids one in an envelope outright, so for SOAP this is conformance rather than strictness, and a payload that needs one should say so explicitly. On the SOAP consumer the refusal comes back as a Sender fault, as any other unreadable request does.
Added — a SOAP fault can be an answer instead of a failure
- Until now the only way to answer with
soap:Faultwas to throw, which left the exchange failed: the route's metrics counted an error, a dead-letter channel took a copy, and a supervising host saw a broken route — for a "no such order" the service is designed to return. Requested by the Tsak agent 2026-09-25, whose DLQ filled with business faults. - Consumer: put
redbSoap.faultString(and optionallyredbSoap.faultCode) on the reply and return normally — the consumer sends the fault and the exchange stays successful. Without a code the fault issoap:Sender/soap:Client: a refusal about the content of the request will refuse the same bytes again, whileReceiveris a retry hint. ThrowingSoapFaultExceptionis unchanged and still a failure. - Producer:
throwOnFault=false(defaulttrue) returns the fault as the reply, withredbSoap.isFaultbeside the code and reason to branch on. MirrorsthrowOnErroron the HTTP producer. - Explicit on both sides rather than inferred from the fault code, which was the other option in the request: a Sender fault can be a bug in the route just as easily as a business answer, and only the route author knows which. Guessing would have made every mis-sent request look like a designed outcome.
Added — SetProperties / <setProperties>: several exchange properties in one step
- The property twin of
SetHeaders:.SetProperties(("a", 1), ("b", Expr("property.a + 1")))in C#,<setProperties><property name="a" value="1"/><property name="b" expr="property.a + 1"/></setProperties>in the markup. The rows run in order, so a later row reads an earlier one; one Message History entry for the whole step. A route that prepared an error reply with threesetPropertyin a row now does it in one step. - The VSCode graph draws it (and
setHeaders) as one tileset ×N; the tooltip lists the rowsname = valuein order, and the properties panel edits each row's name, its kind (=constant,ƒexpression) and its value.
Changed — VSCode graph: a run of setters is one tile, conditional paths are drawn
- Consecutive
setProperty/setHeadersteps, mixed, are joined on the graph into one tileset ×N— the file keeps its elements, only the view joins them. The panel lists the rows in the file's order (the order matters: a later row may read an earlier one); each row switches betweenpropertyandheader(the element is renamed), edits its name,=constant /ƒexpression and value, and is removed on its own; Delete removes the whole run. - Where a message goes when a condition does not hold is drawn, dashed and colored: orange — past the
step (
filternot passed, achoicewithoutotherwisewith no match,ofTypeother type, a duplicate atidempotentConsumer); grey with a cross — dropped (sample,debounce); red — the step raises (validateunlessthrowOnFailure="false",throwException, athrottlerejecting on overflow), captioned where the error goes:→ catchof the enclosing try-catch,→ onException, or→ error. - A folded container is drawn as a stack of cards with a shadow, a count chip of what it hides and a
highlighted
▸; the fold button is a boxed control in the tile's corner. The toolbar folds or unfolds every container at once (⊟ / ⊞), and every route folds to its title (▾ before its name: the entry tile and the count of its steps stay). The properties panel is a property grid — a sticky title with the step's glyph, label | value rows on hairlines, a sticky action bar — and its CONTENT lists the element's own configuration rows only: the steps nested in a branch or a container are no longer shown there as blank text fields. The properties panel is widened by dragging its left edge (remembered for all documents); top down, the columns stand in the middle of the window and the gaps between steps are halved. Lines follow the tiles when the layout shifts after drawing (a scrollbar appearing, the window or the panel resized).
Fixed — HTTP, SignalR, WebSocket and SOAP find the shared Kestrel host in the context's container
- A module host adds components by scanning, so
ServerManageris never assigned, while the oneSharedHttpServerManagersits in the DI container the route context was given. gRPC and AS2 already looked there; these four did not.http:refused to start a consumer at all ("SharedHttpServerManager is not configured" with the manager right there in the container), and SignalR, WebSocket and SOAP quietly started a private listener on their own port instead of multiplexing onto the shared one. Requested by the Tsak agent 2026-09-24 after a module route stopped answering on the shared port. - All six now resolve the same way: explicitly assigned → the service the context was given → what the
component did last anyway. For SignalR, WebSocket, SOAP, gRPC and AS2 that last step is their own
server, so a hand-built component stays usable; for
http:it stays a refusal, because anhttp:consumer binds a port and which port it binds is the host's decision — the message now also names the container as a way to supply it. - The middle step is one method,
IRouteContext.Resolve<T>(): the services set on the context (AddService) first, then the DI container. That order was already written by hand in three places (the metrics subscriber, the template options, the JSONata options) and is now in one; a host with no container —AddServiceand nothing else — reaches the connectors too, which it did not before. - Nothing is required of a host that already works:
AddRedbRouteHttp(),AddRedbRouteSignalR()and the rest keep assigning the manager, which still wins over everything else. - Deliberately not done: a component-customizer hook in the core, where the host would push the manager
in at
AddComponentinstead of the component looking it up. It would only work when the service provider is set before components are added, which the engine does not require of a host, so it would need the lazy lookup kept beside it as a fallback — two mechanisms for one result. The Tsak agent, the only host that builds contexts by scanning, confirmed its own order guarantees this but asked not to carry both paths.
Changed — VSCode extension 0.2.0: the route graph draws tiles and orthogonal edges
- Every step is a tile of one size: a colored glyph in the Enterprise Integration Patterns notation
(Hohpe/Woolf, CC BY 4.0, redrawn as 24×24 line icons) and a short caption —
from,bean,sql,http,set,choice,when,loop,log,try-catch. An endpoint tile shows its transport (bean:#x→bean, the structural<to><sql>→sql, with the SQL in the tooltip) instead of the wordto. The details of a step live in the tooltip and the properties panel. The toolbar switches the glyph over the caption or beside it; the choice is remembered for all documents. - No container draws a box any more. A wrapper (
metered,loop,split, a transaction…) puts its steps into the route's own line between its tile and anend …tile, so they wrap together with the route instead of forming a stair of nested blocks; a loop adds arepeatarrow from its end back to its start and is kept on one row when it fits one.choiceandtryCatchfan out into branches as before, and afilteris drawn the same way: one branch plus a bypass for the message it did not let in. - Edges run in straight segments only. The branches of a choice or a tryCatch merge on a shared bus right before the next step. Before, the exit of a tall container and the merge of a choice were drawn as diagonals across the whole drawing.
- Left to right, the steps of a row hang from one top line, so the line through the tiles stays straight next to a tall branching.
Fixed — a property whose name holds a dot reads the same bare as it does in `$
property.omni.PollIntervalwritten bare — indelay, insetProperty expr, anywhere a value is expected without${}— read as empty, while the same name inside${...}, in a comparison, and any dotted header resolved. A module that namespaces its settings (omni.PollInterval) is the ordinary case, so the value silently became nothing. Reported 2026-09-24.- The bare value path went straight to nested access: it looked for a property called
omniand a memberPollIntervalon it. It now asksResolvePropertySmart, as the header branch beside it and the template pipeline already did — the literal name first, the nested path second. One rule for the whole language. - A nested member of a property (
property.omni.PollIntervalover an object inomni) keeps resolving; a property carrying the literal dotted name wins over it, which is the rule dotted headers have always had.
Documentation — container-level handlers of a markup file apply to the whole context
- The
redb.Route.XmlREADME said thatonException,intercept*andonCompletiondeclared under<routes>apply to the routes of their own file. The engine registers the handlers of every builder for the whole context, so they cover the routes of all the other files and the C# routes of the same context too. The README now says so and advises declaring each handler once; the engine is unchanged.
Added — the pack gate checks the template and the JSONata spec a route names
<payload template="…">and<transformJson spec="…">are checked the wayfile=always was: a file missing fromresources/is a build error, named with the position of the attribute. The check only makes sense now that the worker reads these files from the package (see the entry below); before, the gate would have approved a file the runtime could not find anyway.- Driven by the specs, not by a list of elements:
AttributeSpec.Resourcemarks an attribute that names a package file, and the two package elements opt in (one flag each inredb.Route.Templatesandredb.Route.JsonTransform). The flag is an init property, so a released contribution compiled against the record keeps working and simply goes unchecked until it opts in. - Two kinds of value cannot be checked at build time and are left alone: an
assembly:locator (the text ships inside an assembly) and a value that still holds a placeholder (known once configuration resolves it). The same now holds forfile=, whose error also gains the attribute's position.
Fixed — a template and a JSONata specification are found inside a package, like every other file
<payload template="templates/order.json.sbn">and<transformJson spec="…">in a.tpkgfailed at context start: both packages resolved a relative path against their ownBaseDirectoryonly, which stays the worker's directory, while the package keeps its files inresources/— known to the context'sIRouteResourceResolverand to nothing else.validateXsdandxsltin the very same package found their files, because they ask that resolver. Reported by the Route-XML agent, 2026-09-24.- Both steps now use one lookup: the context resolver first, then
BaseDirectory. An absolute path andassembly:are untouched, and a file in neither place fails with both places named, the way the validator reports its search. The failure stays aTemplateCompilationException/JsonTransformCompilationException— the lookup changed, the contract did not. - The compile cache is keyed by the file that was found, not by the relative name, so two modules that ship the same name do not share a compiled template, and a hot-reloaded module renders its own version. The name in messages stays the locator the route author wrote, not the unpacked package's temp path.
ResourceResolutionis public (Locateis new), andTextSource.FilePathtells a package that a source names a file. A connector that reads a file by a relative path has no other way to follow the framework's search order — the same reasonPredicateFactorywent public for the file filters.
4.1.0
Why 4.1.0 and not a patch. This release carries breaking changes, and every one of them is in an entry marked breaking below: a route that declares no id is named differently,
streamAutoAckis gone,stream=on anllm:endpoint names a mode, and the Redis, Kafka andllm:endpoints now refuse a parameter they cannot read instead of ignoring it. Each entry says what to change. The ecosystem ships from one number, so redb core, redb.Route, redb.Tsak and redb.Identity are all 4.1.0.
Added — the pack gate warns about a condition that compares outside a placeholder
expr="${header.kind} == 'order'"is rendered to text before it is read, and non-empty text is true whatever it says: the branch wins on every message, silently. The gate now names the line and the attribute and shows the two spellings that work. A warning, not an error — the route loads and runs, and what the author meant is theirs to decide.- Only the mixture is reported: a placeholder outside the braces together with an operator outside them. A lone
${header.enabled}renders totrue/falseand is read as a boolean word; a comparison written entirely inside the braces is evaluated, not rendered; and a template with an operator in a VALUE position is ordinary text. - Driven by the specs, not by a list of elements:
AttributeSpec.Conditionmarks a predicate position (filter, everywhen,validate,loop while,retryWhile,normalize/when), so a package contribution that declares its own condition is covered the same way. The flag is an init property — a released contribution compiled against this record keeps working.
Added — the file consumers filter by path, not only by file name (Apache Camel parity)
include/excludeonly ever saw the file name, so "poll these three directories out of two hundred" had no answer: the route had to poll the parent recursively and sort it out afterwards — after downloading every file and afterdelete/movehad already touched the ones it did not want. Four options now decide by path, in File, FTP and SFTP alike, because they live in the shared consumer:antInclude/antExclude— Ant patterns over the path relative to the polled directory (TYPE_A/outbox/*.csv,TYPE_B/outbox/*.csv), where*stops at a separator and**spans levels;antFilterCaseSensitive(defaulttrue, as in Camel) decides whether case matters.filterDirectory— a condition asked per subdirectory before it is listed, so a directory it turns down costs no listing and no round trip. This is what makes one consumer over a partner tree of two hundred directories affordable, and the only way to keep to one connection when the server allows exactly one.filterFile— a condition asked per file before it is read, moved or deleted.filter=#myFilter— anIGenericFileFilterfrom the registry, deciding for files and, throughAcceptDirectory, for directories too. A name the registry does not hold fails when the endpoint is created.
- Anything a filter rejects is left exactly as it was found: no exchange is created for it, so post-processing never reaches it. That is the difference from sorting it out in the route.
- The local listing now walks the tree itself instead of asking for all directories at once — that is what lets a
directory be skipped, and
maxDepthnow stops the descent rather than discarding what was already enumerated.IFileOperations.ListFilesAsynctakes the directory predicate as an optional parameter; a custom implementation of that interface keeps compiling and simply ignores it. PredicateFactoryis public: a connector option that takes a condition string must compile it exactly as the DSL does, and the markup layer learned the same lesson withOnWhen(string)(core, 2026-09-21).
Changed (breaking) — a route that declares no id is named after its endpoint, without a ://
- The default id of a route was its sanitized
From()URI, so every{RouteId}log line, metric tag, health-check entry and dashboard label carrieddirect://orders?...— and a/or a:in a label is what breaks dashboards. It is now the endpoint's scheme, its path and a deterministic UUID of the whole URI, joined with-:direct-orders-1b4e28ba-2fa1-11d2-883f-0016d3cca427. The name holds onlyA-Z a-z 0-9 _ -, and its readable part is capped at 48 characters. - The UUID is RFC 4122 version 5 in the URL namespace over the normalized endpoint URI (
RouteIdFactory), so the same endpoint keeps the same id on every start, in every process and on every node — metric series, stored checkpoints andcontrolbus:route?routeId=...commands still point at the same route after a restart — and any UUID library recomputes it from the URI without this code. - Two endpoints differing only in a parameter now get different ids. Masking used to collapse
?password=oneand?password=twointo one id and the second route was refused as a duplicate, although the URIs differ. - The query string is no longer part of the id, and
user:pw@hostcredentials are dropped from the readable part: a secret cannot reach a log line through the id at all, rather than reaching it masked. - Ids declared with
RouteId("orders-in")orid="orders-in"in markup are untouched. Anything pinned to the old URI-shaped id — a saved dashboard query, a control-bus call, a stored checkpoint — needs the new id or an explicitRouteId().
Fixed — OnException ... Continued() picks the route up after the failed step, as Camel does
Continued()did exactly whatHandled()does: it suppressed the failure and the route ended there, while its own documentation promised routing would continue. Camel'scontinued(true)resumes at the step after the one that failed — it can, because its error handler wraps every step and the pipeline simply moves on.- Here the handler wraps the whole route, so every pipeline the failure unwinds through now records the step it stopped
at, and a continued handler replays the rest: the scope that failed first, then the steps after it. Work a
transaction rolled back is not replayed (routing picks up after the block), nor is a
DoTrybody whose failure aDoCatchtook. A failure among the resumed steps is matched against the handlers anew; it is not redelivered, because a redelivery re-runs the route from its first step.
Added — the three OnException callbacks take a processor, not only a delegate
OnRedelivery,OnExceptionOccurredandOnPrepareFailurehaveIProcessoroverloads beside theAction<IExchange>ones, and both run. A markup route can only name a bean from the registry, and a bean is anIProcessor: bridging it to a delegate would have meant blocking a thread inside the error handler. Camel takes a Processor for all three.- Both places a handler can be declared — in the route and on the
RouteBuilder— pass them on.
Added — <onException> calls beans at the three moments of a failure
onExceptionOccurred,onRedeliveryandonPrepareFailuretake a registry reference (onRedelivery="#stampAttempt") and run the namedIProcessor: on every occurrence, before each retry, and once before the handler takes over for good. A name the registry does not hold is refused at load, by position.- They ride the engine's processor overloads (core, 2026-09-21); the markup could not use the older
Actionform, because a bean arrives asynchronous and bridging it would mean blocking a thread mid-failure. - The README of
redb.Route.Xmldocuments the handler's whole policy, whatcontinuedresumes and what it does not replay (the failing step, a rolled-back.Transacted()block, a body whose failure a<catch>took), and that a condition isheader.kind == 'order'—${header.kind} == 'order'is a template and always true.
Added — the markup spells the whole retry policy of <onException>
continued,useOriginalBody,logStackTrace,logExhausted,retryAttemptedLogLevelandretriesExhaustedLogLevelare attributes now; an unknown level is a positioned error, not a silent default.<when expr="…"/>decides whether this handler takes the failure at all,<retryWhile expr="…"/>whether another attempt follows — conditions of the scope, spelled the way an<intercept>spells its own.OnExceptionDefinitiongains the string overloadsOnWhen(string)andRetryWhile(string), which compile the condition exactly asInterceptDefinition.When(string)does: a declarative route has no other way to write a predicate, and the markup layer cannot reach the internal predicate factory.continuedresumes the route at the step after the one that failed, as Camel does — the engine gained that on 2026-09-21 after this markup work reported the difference;handledstill ends the route after the handler.
Added — <transaction>, <threads> and <metered> take the knobs their DSL already had
<transaction deadLetterChannel="…" retryAttempts="4" retryDelay="00:00:05">. Attempts and delay are one knob: half of it is refused by name instead of retrying after a pause nobody wrote.<threads poolSize="4" maxQueueSize="100" enqueueTimeout="00:00:03">— the queue in front of the pool is bounded from the markup.<metered name="orders"><tag name="partner" fromHeader="partnerId"/><tag name="kind" expr="header.kind"/>— the metric splits by tags; a<tag>takes a header or an expression, and neither (or both) is refused.
Fixed — the component catalog was missing the engine's own schemes
timer,direct,direct-vm,seda,vm,mock,bean,controlbus,validatorandxsltnever reached the catalog: the tool walkedredb.Route.*.dll, and the engine isredb.Route.dll, which that mask does not match. The schemes most routes start from had no completion and no structured form; the catalog goes from 38 to 48 components.- The tool now reads the engine and the markup library from its own (Default) load context instead of loading a
second copy from the scanned directory: a second copy is a DIFFERENT
IComponent, so every connector's cast failed silently and the catalog came out with four components. - The catalog also catches up with
localTransactions(AMQP),batchCommit(Azure Service Bus) and the path filters the file consumers gained —antInclude,antExclude,antFilterCaseSensitive,filterDirectory,filterFileandfilteronfile,ftpandsftpalike.
Fixed — the generated catalog schema did not compile
- A component whose structured path synonym is also one of its options —
seda'squeue,amqp'spath— declared that attribute twice, and the schema failed to compile with «the attribute 'queue' already exists» (twelve times over the real catalog, the 4.0.1 one included). The option wins: it carries the type and the enum values, the synonym only names the path.
Fixed — the XML twin of the SerialNumbers demo always took the first branch
- Its conditions were written
expr="${header.serials.decision} == 'Accepted'". With${…}the line is a TEMPLATE, and a template in a condition position renders to a non-empty string, which is true whatever the header holds. Spelledheader.serials.decision == 'Accepted'it compares. A test pins that a header whose name carries dots is read by a condition, since that is what the demo relies on.
Added — AMQP local transactions and Azure Service Bus batch commit for the sends of a block
- AMQP 1.0:
localTransactions=true(.LocalTransactions()) commits the sends a producer deferred in a.Transacted()block in one AMQP local transaction: a coordinator link,Declare, every send carrying the transaction, oneDischarge— all of them arrive or none. A send that cannot complete (refused, or not settled withintimeoutseconds) discharges the transaction as failed. Driven on the protocol's own types: the client'sSystem.Transactionspath never got an answer to its discharge, the same broker answers these at once. The broker must support local transactions (ActiveMQ Artemis, Qpid, Azure Service Bus; not RabbitMQ's AMQP 1.0 plugin). - Azure Service Bus:
batchCommit=true(.BatchCommit()) sends them as one batch, which the entity takes whole or not at all. A block whose messages do not fit in one batch fails its commit and sends nothing. - Both are options: without them the deferred sends go out one after the other, as before.
Fixed — Azure Service Bus batch mode dropped messages without a word
enableBatch=truestopped atbatchMaxMessagesitems and at the first item that did not fit inbatchMaxSizeBytes, and sent the rest silently truncated. Either now fails the send, and nothing is sent.
Fixed — Redis consumers: PSUBSCRIBE, XREAD without a group, acknowledgement, pending entries, reliable lists
PSUBSCRIBEsubscribed to its pattern as a literal channel name unlessusePattern=truewas added by hand, and so received nothing. It is a pattern subscription by itself now.XREADwithout a consumer group read from the same position on every poll: withstreamStartPosition=0it processed the same entries forever, with the default>(a group-only position) every read failed. The consumer now keeps its position and moves it past every entry read. The start position is unset by default: without a group that means the entries added from now on (the stream's last id is pinned once when the consumer starts), with a group the group is created at the stream's end;>without a group is refused.- Breaking:
streamAutoAckis gone.streamAutoAck=falseread with NOACK while its name promised a manual acknowledgement. A group consumer acknowledges an entry after its route succeeded and leaves a failed one pending;streamNoAck=true(.StreamNoAck()) reads with NOACK (at-most-once). The old parameter is refused with this explanation; the deadRedisStreamAckActionis removed. streamClaimMinIdleMs(.StreamClaimMinIdle(ms)): a group consumer claims (XAUTOCLAIM) entries of its group pending for at least that long — a failed entry of its own, or one a dead consumer left — and processes them again.processingList(.ProcessingList(key)): aBLPOP/BRPOPconsumer moves each item there (LMOVE) and removes it after success; a failed item goes back to the head of the queue, and what a previous run left is returned at start. Without it the consumer pops, and a failed item is gone, as before. Both still poll: a blocking pop would hold the connection the whole process shares.- A Redis endpoint refuses a parameter it cannot read (a misspelt option, a value of the wrong type), by name, as the
Kafka and
llm:endpoints do. The Redis README is rewritten where it did not match the code (the URI format, the namespace, the connection factory example, the method table) and gains a table of what each consumer guarantees.
Added — Kafka transactions and exactly-once consume-process-produce (transactionalIdPrefix)
transactionalIdPrefix(Kafka.Topic(...).TransactionalIdPrefix(...)) switches a producer to Kafka transactions. The sends it defers in a.Transacted()block commit as one Kafka transaction when the block commits, after the database: all of them or none, andread_committedreaders (librdkafka's default) never see an aborted one. A send outside a block is a transaction of its own.- When the route started from a Kafka consumer of the same cluster, the consumed offset commits in that transaction
(
SendOffsetsToTransaction) and the consumer does not commit it itself: the output and the offset move together, exactly-once within Kafka. WithenableAutoCommit=falsethe offset stays the application's. - The prefix names the producer; the connector appends the machine name, the process id and the producer's number, so nodes deploying one configuration, and two producers of one process, never fence each other. A fatal producer error rebuilds the producer with the same id before the next transaction.
- Unlike camel-kafka, the Kafka transaction spans the block's commit, not the whole exchange: a producer holds one open transaction at a time, and one held for a whole exchange would make every exchange wait for the one before.
Changed — a Kafka endpoint refuses a parameter it cannot read
- A misspelt option, or a value of the wrong type, used to be dropped without a word: a misspelt
transactionalIdPrefixwould have left the producer without transactions. It is refused by name when the endpoint is created (the value is not echoed: it may be a secret), as thellm:endpoint does. Atransactional.idinadditionalPropertiesis refused too: it would put the client into transactional mode with nobody opening transactions.
Fixed — OnCompletion and the exchange events reach the consumer's verdict
- A route whose
OnExceptionhas noHandledreturns normally with the failure left on the exchange, and.RollbackAll()returns normally with the exchange marked rollback-only; the consumer acknowledges neither. TheOnCompletionblocks and the exchange events (OnExchangeCompleted/OnExchangeFailed, and soNotifyBuilder) judged by the escaping exception alone and took both for completions:OnFailureOnly()did not run,OnCompleteOnly()did, listeners heard "completed". Both are failures now; a handled failure stays a completion.
Fixed — the idempotent consumer settles its key when the exchange ends, as Camel does
- The key was confirmed at the end of the
IdempotentConsumerblock. A step after the block that failed, or.RollbackAll()inside it, left the key confirmed while the consumer did not acknowledge the message: the redelivery was skipped as a duplicate and the message was lost. The key is now settled when the exchange's unit of work ends — confirmed on success, removed on failure anywhere in the route (Camel's defaultscompletionEager=false,removeOnFailure=true) — before the route returns to its consumer, so the key is back before the message goes back to the broker and a redelivery another node picks up at once is processed. OnExceptionredelivers the whole route, where Camel redelivers the failed step: the exchange that holds a key is no longer a duplicate of itself. A block that failed runs again; a block that succeeded is skipped on the redelivery, without theCamelDuplicateMessagemark it used to get.- A key claimed inside
.Transacted()follows the transaction: it commits with the work and goes with a rollback.IIdempotentRepository.JoinsAmbientTransaction(default false, true forRedbIdempotentRepository) says whether the repository's writes are part of the transaction; for one that is not — the in-memory repository — the processor removes the key when the transaction rolls back. It used to keep such a key, and the redelivery of work that had rolled back was skipped. SqlIdempotentRepository(redb.Route.Sql) reportsJoinsAmbientTransactionfrom its connection: true on PostgreSQL (Npgsql) and SQL Server (SqlClient), whose connections enlist in the route transaction, false on other providers (Microsoft.Data.Sqlite does not enlist) and withEnlist=false. It reported false everywhere, so after a rollback on PostgreSQL and SQL Server the consumer deleted a key the rollback had already taken back, and in a cluster that delete could remove the key another node had claimed meanwhile.SqlIdempotentRepositoryandSqlClaimCheckRepositorycreate their table outside the route transaction, as Camel creates it outside any exchange. The first call used to run theCREATE TABLEinside the transaction of whatever message came first: when that transaction rolled back, PostgreSQL and SQL Server took the table back with it, the repository still took it for created, and every later call failed ("relation does not exist", "Invalid object name") until the process restarted. On MySQL and Oracle the same DDL would have committed that transaction implicitly.- A Split or Multicast branch is a unit of work of its own. Split now records a failed branch's exception on the branch, as Multicast and RecipientList already did.
Added — ExchangeResources.OnCompletion: work on the outcome of an exchange
ExchangeResources.OnCompletion(exchange, IExchangeCompletion)is Camel'saddOnCompletionwith aSynchronization:OnCompleteorOnFailureruns once when the exchange's unit of work ends. The outermost route the exchange entered owns the unit of work and ends it before returning to its consumer; a route called with the same exchange (direct:) shares it; an exchange no route owns (a Split or Multicast branch, the copy.Threads()goes on with) ends it when it is released. Failure is the consumer's verdict: an escaping exception, a failure left unhandled on the exchange, or a rollback-only mark.ReleaseWithExchangestays for resources that are released either way.
Changed — the sends of a block leave in order, and one producer's sends commit in one broker transaction
- The deferred actions of a
.Transacted()block were kept in a dictionary and committed in whatever order it gave: two sends to one queue in a block could arrive swapped. They now commit in the order the route registered them. - IBM MQ and RabbitMQ commit the deferred sends of one producer in a block in one transaction of their own, so they
arrive together or not at all: IBM MQ puts them all under syncpoint and issues one
MQCMIT(a failed put backs them all out); RabbitMQ publishes them on a transacted channel of the producer's own and issues onetx.commit. The producer's batches take turns on its connection. Atomic means per producer: sends through two producers, or to two brokers, still commit one after the other. TransactedActions.JoinBatch(exchange, key, create, endpoint)lets a producer of your own keep one batch per block.- AMQP 1.0 keeps sending one message after the other: a batch in an AMQP transaction through the client's
System.Transactionssupport never got an answer to its discharge from the broker and hung until the connection's idle timeout. Kafka transactions and Azure Service Bus are separate work.
Documentation — the outbox recipe for routes that do not start from a broker
TRANSACTIONS.mdshows the transactional outbox as two ordinary routes, as in Camel: the event is a row written in the same transaction as the work, and asql:poll relays unsent rows and marks each one after the send. It closes the one window the database-first order leaves open (the process dies after the commit) for routes started by HTTP, a timer or a file, where nothing delivers the request again.
Changed — Kafka producers default to acks=all and an idempotent producer
- The Kafka 3 client and Camel 4 default to
acks=allwith idempotence on; the connector defaulted toacks=leader, weaker than both, and turned idempotence on only withtransacted=true.acksnow defaults toall(endpoint andKafkaConnectionFactory), and the newenableIdempotence(.EnableIdempotence()in the builder), left unset, follows the effectiveacks: on withall, off otherwise.enableIdempotence=truewith anotheracks, andtransacted=truewith an explicitacksother thanall(it used to be overridden silently), are refused when the endpoint is created. A route that wantsacks=leadersets it explicitly.
Fixed — RequestBody refuses a reply it cannot hand over alive, and a reply that does not convert throws
ProducerTemplate.RequestBodyends the exchange before it returns, so a reply that reads from resources of its exchange (a streamedsql:result withoutputType=StreamList) came back already released and failed at its first read with "was released when its exchange ended". Such a body now carries the new markerIExchangeBoundBody, andRequestBodyrefuses it at the call withInvalidOperationExceptionthat points toRequestAsync: read the body, then dispose the exchange. The TestKit helpersRequestBody<T>andRequestBodyAndHeaders<T>do the same. Found by the Llm connector review; thesql:stream takes the marker in its own change.ProducerTemplate.RequestBody<T>swallowed a failed conversion and returneddefault: a reply of the wrong type came back as0ornull. It now throwsInvalidCastExceptionnaming both types. A nullableTconverts to its underlying type, soRequestBody<int?>on the reply"42"returns42instead ofnull(the TestKit helpers threw there).
Added — the pack gate names steps that never run
<stop/>,<throwException>and, since the transactions work,<rollbackAll/>end the route for the exchange. A step written after one of them in the same list never runs; the gate warns with the position of the first such step. The property is part of the element spec (ElementSpec.Terminal, an init-only property so contributions compiled against 4.0.1 keep binding), so a package element that ends the route is checked the same way.- The component catalog follows the tri-state
transactedof the producers: the options that becamebool?lose their false default (unset now means «wait for the enclosing transaction»), andwmq,sqsandsnsgain the attribute asxs:boolean. The structured endpoint form keeps an unwritten attribute out of the URI and carries an explicitfalseinto it; a test pins both.
Changed — inside .Transacted() a broker send joins the transaction unless transacted=false says otherwise
- The rule is Camel's: inside a transacted route, a send through a transactional endpoint is part of the unit of work.
Until now a producer deferred its send only with
transacted=true; without it a.To(broker)inside.Transacted()went out at once, before the database committed and even when the block rolled back, although the guide and the examples showed it deferred.transactedon a producer is nowbool?: unset follows the block (deferred inside one, at once outside),falsesends at once even inside a block,truerequires a block. The fluent builders take.Transacted(false). - Covered: RabbitMQ, AMQP 1.0, Kafka, IBM MQ, and now Azure Service Bus, SQS and SNS, which had no deferred send at
all. Kafka's
transacted=truestill also makes the producer idempotent. - Redis follows the same rule for what announces work:
PUBLISHandXADDwait for the commit inside a block, and the entry id and the recipient count reach the headers after it. Other writes run at once unlesstransacted=true, and reads and pops (GET,LPOPand the like) refusetransacted=truewhen the route starts. - A request-reply producer (
replyTo=true) always sends at once and refusestransacted=trueat start: a request held back until the commit would wait for a reply that cannot come. - The AMQP 1.0 and Azure Service Bus clients enlist a send in the ambient
System.Transactionstransaction on their own, so an "immediate" send inside a block was in fact enlisted, and next to a database it would escalate the transaction to a distributed one. A send that leaves at once now runs with the ambient transaction suppressed, and deferred actions run outside any transaction. - Upgrading: a route with
.Transacted()whose broker send must leave even when the work rolls back needstransacted=falseon that endpoint.TransactedActions.Defers(exchange, transacted)andTransactedActions.RegisterSend(...)give a producer of your own the same behaviour.
Fixed — a block inside a block follows its transaction policy
- A
.Transacted()inside another one committed or rolled back every deferred send of the exchange when it ended, the outer block's too, and before the outer database commit. Now aRequired(orMandatory) inner block joins the outer one and leaves its sends to the outer commit, and aRequiresNeworSuppressblock keeps a set of its own and settles only its own sends. .CommitTransaction()sent the deferred messages before it committed the database, the reverse of the order.Transacted()uses. It now commits the database first, and after a send fails past the commit it rolls back what is left and propagates..BeginTransaction()follows the same nesting rules.seda:,vm:and an InOnly.Threads()hand a copy of the exchange to another flow, and the copy carried the sending block's set of deferred actions: a send on the other side went into a set that the sending block may already have committed, and was lost. The hand-off now starts a unit of work of its own, as in Camel. A block also closes its set when it ends, so any copy that outlives it (an aggregator's, a resequencer's) sees no block at all.
Fixed — .RollbackAll() rolls the transaction back, as Camel's markRollbackOnly() does
.RollbackAll()rolled back the deferred sends and set aRollbackOnlyproperty that nothing read: the route went on, the enclosing.Transacted()committed the database, and the consumer acknowledged the message. It now marks the unit of work for rollback and stops the route; the block rolls back the database and the sends without an exception, a joined inner block takes the whole unit of work with it, and an open.BeginTransaction()is rolled back on the spot. The consumers handle a rollback-only exchange as a failed delivery and do not acknowledge it: RabbitMQ, AMQP, IBM MQ, MQTT, Redis, Azure Service Bus and SQS deliver the message again, Kafka follows its failure setting (breakOnFirstError), and the file consumers take their failure path.ExchangeFailureExtensions.MarkRollbackOnly(),IsRollbackOnly()andEndedInFailure()expose the mark.
Fixed — Redis: a deferred write is the same write, and binary bodies stay binary
- A deferred Redis write (
transacted=true) went through a separate, reduced code path: it dropped the TTL, used a fixed hour forEXPIRE, turned the body into text and did nothing at all forHMSET,GEOADD,PFADD,PFMERGEandSETBIT. It now runs the same operation after the commit, on a snapshot of the exchange taken at the step. Found in the Redis connector review (item 4). SET,SETNXandPUBLISHwrote abyte[]body as the textSystem.Byte[]; they now write its bytes, as the other operations did (item 6).
Fixed — a transacted send outside .Transacted() fails instead of disappearing
- A producer with
transacted=truedefers its send or write until the enclosing.Transacted()block has committed the database. On a route without the block, or on a step after the block has ended, each producer (RabbitMQ, AMQP 1.0, Kafka, IBM MQ, Redis) created the set of deferred actions itself; nothing ever committed it, and the write was silently lost. The block now owns the set: it opens it on entry and removes it on exit, a nested block reuses the outer one, and the producers register throughTransactedActions.Register, which throwsInvalidOperationExceptionnaming the endpoint when no block is active. The imperative block does the same:.CommitTransaction()and.RollbackTransaction()remove the set that.BeginTransaction()opened, so a transacted send after them is refused too. Found in the Redis connector review (item 5).
Changed — the graph editor has two layouts, both in the mermaid look
⇢ mermaid, the default: the left-to-right snake with the shapes and curves of the mermaid look. A step is left on its right and entered on its left, a branching fans out from a decision diamond, a bracket shows its title beside the body, and a wrapped row returns along the lane between the rows with rounded corners.⇣ mermaidis the same drawing top down. One overlay draws the curves of both directions.- The layouts drawn with CSS borders are retired, the plain top-down
columnsand the plain snake with its elbows: the two mermaid layouts are those arrangements drawn with curves. A document that remembered a retired layout opens in the default one. - The tooltip of a branch carries its whole condition. The label is capped so that a stack of branches stays readable; the tooltip was capped with it and showed the same cut text.
Fixed — the graph editor shows the flow leaving a bracket
- In the Mermaid-look layout the branches that end a bracket (
transaction,filter,try) stopped inside the box: no line came out of their last steps although the flow goes on after the scope. Every tail of the last inner step (every branch, when it is a branching) now curves to the middle of the bracket's bottom edge, where the edge to the next step starts.
Fixed — redb-route-xml csharp and mermaid work on a route project
- A route under
{project}/routes/references its schemas by file name, and the package keeps them in{project}/resources/, where the pack gate and the Tsak loader look. The generator and the diagram command looked next to the route file instead, so both failed on<validateXsd file=…>of any real project.RoutePackage.ResourceRootFor(routeFile)states the rule once, and both use it; a loose route file still reads its own directory. mermaid --bin <build output>loads the project's types and the package elements of its build output, the same waycheckandpackdo: a route that names<unmarshal target=>of its own or uses<redbSave>draws. Without--binsuch a route stops with a hint to pass it.
Fixed — the pack gate resolves every type a route names
- With the built assemblies at hand (
--bin, or the build-timePackRouteOnBuildtarget) the gate checked bean types and methods but not the other type attributes, so a typo in<unmarshal target=>or an unknown exception inexceptions=reached the worker and failed there at load. The gate now resolves every attribute the element specs declare as a type, walking the specs generically, so package contributions are covered the same way; each miss is an error with its position. Found on the SerialNumbers XML demo:exceptions="…ValidationException, redb.Route"is a LIST, the assembly suffix split into a second entry that does not exist. AttributeType.TypeNameList(appended last) marks list-valued type attributes, so the gate splits them on commas while a singleTypeNamekeeps its assembly-qualified form. The enum's numeric values are pinned by a test: package contributions compiled against an earlier version store them.
Changed — breaking — an llm: endpoint refuses a parameter it cannot read
- A parameter no
llm:option has (tempreature=0.2), or a value that does not convert to the option's type (maxIterations=abc), used to be dropped without a word: the endpoint ran with the option's default. Such a parameter now fails the endpoint's creation, each one named, with the nearest option for a misspelt name (did you mean 'temperature'?) and the expected type or values for a bad value. - Migration: a route whose
llm:URI carries a parameter the endpoint does not know stops at start instead of running without it; remove the parameter or correct its name.
Changed — breaking — stream= names a mode, and both modes run the agent engine with its tools
stream=calls(DSL:.Stream(LlmStreamMode.Calls)) — the agent engine makes every model call as a stream, inside the route. Tools, the conversation, the budget, approvals, the audit and the route's.Transacted()work exactly as without streaming: the loop goes on with the whole answer the stream assembled. The pieces go to the newIAgentObserver.OnDeltaAsyncas the model writes them, visible text and thinking marked apart (AgentDeltaKind);Out.Bodyis the final text. The connection carries data while the model writes, so a long answer from a tool-using agent survives tunnels and proxies that cut a silent connection, over HTTP/1.1 too. A provider whose stream ends without the assembled answer (LlmStreamChunk.Response) fails the call.stream=body(DSL:.Stream(LlmStreamMode.Body)) streams the text intoOut.Bodyas anIAsyncEnumerable<string>for HTTP (SSE), WebSocket and gRPC consumers, asstream=truedid, but no longer bypasses the engine: the agent run — tools, conversation, budget, approvals, audit — happens when the body is read, after the route, the way a streamed SQL result is read, and the visible text of every model call streams as the model writes it (thinking stays out).tools=with it is allowed. The body belongs to its exchange (IExchangeBoundBody): it is read once, released with the exchange (a read after the exchange ended fails, a run in progress is stopped), and refused byProducerTemplate.RequestBody, which ends the exchange before it returns. It is refused inside.Transacted(): the block would commit before the answer exists, and the tools would not join it; usestream=callsthere. The reply'sContent-Typeistext/event-streamwhatever the request carried: atext/plainrequest used to make the HTTP consumer drop the SSE framing and the summary event.- Migration:
stream=trueis refused when the endpoint is created, with both modes named; usestream=bodyfor what it did..Stream()without a mode is gone. Any other value is refused too: it used to be ignored silently, so a misspelt mode did not stream at all.
Fixed — a streamed model call assembles the same answer as a plain one
- A streamed call now ends with the whole answer,
LlmStreamChunk.Response: exactly whatCompleteAsyncreturns for it — blocks in the order they arrived, thinking with its signature, redacted thinking, tool calls, usage, the stop reason and its raw value, the response id. The stream is assembled into the JSON a plain call returns and read by the same parser, so the two forms cannot drift apart. Streaming through the agent engine is built on it. - The model's thinking is no longer dropped from a stream: DeepSeek's
reasoning_contentand Anthropic'sthinking_deltaarrive asLlmThinkingBlockpieces, apart from the visible text, and Anthropic's signature and redacted thinking are kept for the hand-back. - A frame that cannot be read, and an error frame inside an OpenAI-compatible stream, used to be skipped: the answer went on shorter than the model wrote it. Both now fail, and the message shows the frame.
- A stream that ends before the model said why it stopped (no
finish_reason/stop_reason) used to count as a finished answer. It is a cut one, and now fails. - The OpenAI-compatible stream no longer reads synchronously (
StreamReader.EndOfStream) in its asynchronous loop, which held a thread-pool thread for every wait between frames.
Changed — RequestTimeoutMs limits the whole model call; the default is ten minutes
LlmConnectionFactory.RequestTimeoutMswas the client'sHttpClient.Timeout, and every provider reads the answer withResponseHeadersRead, under which that timeout stops at the response headers. So one option meant two things. A server that sends the headers at once and the body when the model has finished (DeepSeek does) was never limited: the generation could run for any time, and a server that stalled after the headers was waited for forever. A server that answers only when done was limited for the whole generation. A client passed in by the host was never limited by the factory at all.- Now the limit covers the whole call, waiting for the answer and reading it, for the chat providers
(OpenAI-compatible and Anthropic), transcription and embeddings, whatever client sends the request. The
package's own clients no longer carry a
Timeout; a client passed in by the host keeps its own as well. - Running out throws
LlmTimeoutException(aTimeoutException) naming the provider, the factory and the limit. It used to be aTaskCanceledException, which the agent engine reported to observers as a cancelled run. A cancellation by the caller is still anOperationCanceledException. - The default goes from 120 000 to 600 000 ms, the ten minutes of the official Anthropic and OpenAI SDKs: a
non-streaming answer from a thinking model takes minutes. A factory that relied on the old default now waits up
to ten minutes; set
RequestTimeoutMsto keep a shorter limit. - A streamed call is limited the same way, from the send to the last piece.
StreamIdleTimeoutMs(new, off by default) limits a stream's silence: how long the provider may send nothing at all, not even a keep-alive comment orping. It counts only the waits on the provider, not the time the reader spends between pieces; running out throwsLlmTimeoutExceptionwithKind = StreamIdle. It is off by default because some reasoning models stream nothing for minutes before their first token. - Code that caught
TaskCanceledExceptionto detect a model timeout should catchLlmTimeoutException(orTimeoutException).
Fixed — every model client keeps a long silent call alive, not only Anthropic's
- A non-streaming model call is one request whose answer arrives only when the model has finished, with nothing
on the wire meanwhile, and VPN tunnels, NAT and proxies drop a TLS connection that is silent for about 50
seconds. The Anthropic client already asked for HTTP/2 and sent a keep-alive PING every 15 seconds while a
request was in flight; the OpenAI-compatible client (DeepSeek, OpenAI, Groq, Gemini and the rest), the
transcription client and the MCP HTTP+SSE transport did not, and a long thinking call through such a network
was cut. Their default clients now do the same, and every request they build carries the client's HTTP version.
A client the caller passes in is used as it is. Over HTTP/1.1 and on plain
http://endpoints nothing changes. Embeddings are left as they were: their calls are short.
4.0.1
Added
LlmThinkingBlock(redb.Route.Llm.Providers) — the thinking a provider returns is carried as a content block of its own (Anthropicthinking/redacted_thinkingwith their signature, DeepSeekreasoning_content) and handed back unchanged, in the original order, on the next request of the run. Each request path sends what its wire can express: Anthropic a signed or redacted block, the OpenAI-compatible path the block's text asreasoning_contentwheretoolsare declared. Thinking is never shown — it stays out ofAgentResponse.TextandOut.Body. Streaming responses do not carry thinking blocks yet.- Thinking blocks in the conversation record (
redb.Route.Llm.Storage.Redb) —MessageContentBlockgains kind"thinking"with the thought in a field of its own, the signature beside it and the redacted payload, so a saved conversation replays a signed block byte-for-byte instead of losing it; a conversation continued on another provider goes to it as it was written. A turn of nothing but reasoning is written like any other turn, with its usage and stop reason. IToolClaimsSource(redb.Route.Llm.Abstractions) — resolves the claims of a run's principal, plus the default implementationExchangePrincipalClaimsSource. The agent loop verifiesLlmToolSafety.RequiredClaimsbefore approval and denies a call it cannot verify (fail closed). The default source reads the caller's principal from the exchange (ExchangePrincipal— a property, so a client cannot send it as a header) and takes itsscope/scpvalues, split on whitespace only (RFC 6749 §3.3 — a comma is a legal character inside a scope token, not a separator); a deployment with another vocabulary registers its own source and wins. A principal whose identity is not authenticated counts as no principal, so a resolver must return an identity with an authentication type. If a host removes the default source, a claim-declaring tool stops building at route build, with the required registration in the message — a requirement that can never be satisfied does not ship silently.ICostCalculator(redb.Route.Llm) — prices provider responses so cost budgets mean something.- Budget URI options
budgetInputTokens,budgetOutputTokens,budgetCostUsd, plus.Budget(...)on the fluent LLM builder andWithBudget(...)on the inline.Llm(...)builder. StoreBudgetEnforcer— cross-run budget enforcement overICostBudgetStore, registered byAddRedbLlmStorage().ToolSkipReasons/ToolResultErrors— the observer-channel skip reasons and the model-facingtool_resulterror codes as constants instead of scattered literals.AgentEngine.FromContext(IRouteContext)— builds an engine from whatever the route context and its DI container provide, falling back to the shipped no-op collaborators.AddRedbRouteLlm()registers the engine through it, so one extension call yields every seam (producer template, claims source, cache, cost calculator) instead of depending on the container's constructor selection.
Fixed — concurrent LLM runs no longer share one redb instance and connection
- Scheduled
llm:prompts.From("llm://...?schedule=...")resolvedinitialBodyRefandsystemPromptRefwithout an exchange, so a redb-backedIPromptTemplateRegistryanswered every concurrent tick from one shared instance and connection, and a second tick's lookup could be refused. The tick now creates its exchange first, sets the named redb on it, resolves both prompts with it and only then writes the initial body. The lookup runs inside the block that owns the exchange, so a failing template releases the exchange and its DI scope instead of leaking them. - Budget and audit.
StoreBudgetEnforcer(throughRedbCostBudgetStore) andRedbAuditObserverrun without an exchange and usedGetRedbService(name, exchange: null), so every concurrent agent run shared one instance and one connection: a second simultaneous command was refused, and the audit row was lost. Each call now opens a scope of its own withIRouteContext.CreateRedbScope(name)and releases it. A refusal to open that scope is a configuration error, and the budget call fails with it. The budget still runs outside the route's transaction; the audit row stays in it and rolls back with the tool writes it describes. - Audit failures.
RedbAuditObserverswallowed a failed write without a trace. It still never breaks the run, but the failure is now logged as a warning with the tool name, thetool_useid and the exchange id; the logger comes from the route context like the engine's.
Fixed — the XML pack gate no longer reads SQL parameters as bean references
redb-route-xml check|pack(redb.Route.Xmlpackaging) looked for registry references with a#namesearch over the whole attribute, so every Camel-style SQL parameter of thesql:connector (VALUES (:#login, :#at)) was reported as an undeclared, possibly dangling bean. A reference is now recognised by position, as the format convention states: a value that starts with#(the whole attribute, the path right after the scheme as inbean:#x, or a URI option value as indataSource=#main-db). A#inside the SQL text is text. XML routes use the same:#namesyntax.
Added — a public transactions guide at the root
TRANSACTIONS.mdjoinsMETRICS.mdandCONCURRENCY.mdas a public guide: the transaction model, one route end to end withRetry, a dead-letter channel and an idempotent consumer, the order a unit of work commits in, who acknowledges the incoming message, why.WireTap(...)stays outside, writing anITransactedAction, one database per transacted route, parallelism, and the migration offBeginRedbTransaction(). The README section on deferred acknowledgement is corrected to the current order.
Changed — behavioural — the unit of work commits database first, brokers after
.Transacted()used to flush the deferred transport actions before completing itsTransactionScope, so a message could be sent, or an incoming one acknowledged, while the database work was still uncommitted, and a failing database commit then left a message pointing at rows nobody could read. The order is now: the database transaction completes and closes, then the deferred sends go out. The failure window is "written but not announced" — the broker redelivers and the idempotent consumer absorbs the duplicate — never "announced but not written". A send that fails after the database committed is logged as such and propagates, so the message is not acknowledged and the work is picked up again.Retryinside.Transacted()now wraps the transaction instead of living inside it. One attempt is one unit of work: a failed attempt rolls back its own database work and its own deferred sends, and the next attempt starts with an empty set, so the attempt that succeeds no longer publishes the messages of the attempts that failed. No transaction is held open across the retry delays either, which is what made a transacted broker route hold locks for the whole retry sequence.- The inbound acknowledgement belongs to the consumer, not to the transaction. RabbitMQ, AMQP 1.0, Kafka
(single and batch), IBM MQ (poll and XMS), Redis Streams, Azure Service Bus (queue and session) and SQS no longer
register the ack as an
ITransactedAction; each settles after the whole unit of work, the route transaction included, has succeeded, and leaves the message unsettled when it has not. Rolling an ack back is a nack — an action on the broker, not an undo of work — and withRetryoutside the transaction it would have made the broker redeliver a message while the local retry was still running on it. Outgoing sends stay deferred and transactional. - What this changes for an implementer of
ITransactedAction: the interface is the same, butCommitis now called after the database transaction has closed, soTransaction.Currentis null there; an action that counted on running inside the ambient transaction must be revisited.
Added — the exchange trail and the OpenTelemetry layer as expression values
messageHistory(kind)in the expression language: the steps an exchange went through, as a value.table(the default) is the dump the failure handler prints,compactone line,jsonan array;count,totalMsandslowestMsare numbers,slowestandlastNodename a step. The numbers make it a predicate, so a route logs the trail only when it cost something:.When("messageHistory(\x27slowestMs\x27) > 500"). Message history is opt-in, so an exchange that recorded none reads as an empty string and zero rather than failing a diagnostic log; an unknown kind fails the route build.stats()reads the OpenTelemetry layer when its target starts withotel::stats(\x27otel:redb.route.step.duration/enrich\x27, \x27max\x27), targetotel:instrument[@route][/step], fieldcount,sum,min,maxorlast. The EIP counters (throttle.delayed,circuitbreaker.tripped, …) and.Metered()durations live only in that layer, and until now only C# could read them through the snapshot. Needs the in-process subscriber (UseMetricsSnapshot()) and says so when it is missing; an instrument nobody measured reads as zero; a literal unknown field fails the route build.- Both functions work in markup as well as in C#: XML routes have no lambdas, so
<log message="${messageHistory()}"/>and<when expr="messageHistory(\x27slowestMs\x27) > 500">were impossible to express before.
Changed — the AWS connectors resolve one AWSSDK.Core
redb.Route.S3was onAWSSDK.S34.0.20.3 whileredb.Route.Sqswas onAWSSDK.SQSandAWSSDK.SimpleNotificationService4.0.100.2, so the two connectors resolved differentAWSSDK.Coreversions. Side by side in one deployment layer (the Tsak worker publishes every connector into a shared folder) that is a file in two versions, and the layer build stops on it. All three packages are now on the same SDK train:AWSSDK.S34.0.103.3,AWSSDK.SQSandAWSSDK.SimpleNotificationService4.0.100.14, and both projects resolveAWSSDK.Core4.0.102.6 (checked inobj/project.assets.json). No code change; the test projects follow the same versions.
Changed — behavioural — a host outside Tsak gets a redb scope per exchange
- A host that runs routes itself (a console, ASP.NET, a worker) and registers a named database with
context.RegisterRedbService(name, instance)publishes no scope factory. With an exchange,GetRedbService(name, exchange)handed every exchange that one instance: one connection for all parallel exchanges, and parallel explicit calls on it refused by redb's command gate. An instance built through a DI container now opens a scope of the same database per exchange throughIRedbScopeSource(redb.Core), cached on the exchange and released with it, as the published factory does under Tsak. The same holds for a default service registered on the context when the context's service provider has no redb of its own. - What changes for such a host: a connection per parallel exchange instead of one shared connection (size the
pool for it); the steps of a route no longer see work done on the registered instance outside the route, for
example a transaction opened on it by host code.
.Transacted()is unaffected: one connection per transaction and database. - Unchanged: Tsak, whose published factory is used first; code without an exchange, which keeps the registered instance; an instance built without a container, which stays shared.
Added — a scope of its own for redb work without an exchange
context.CreateRedbScope(name)(redb.Route.Core) opens a new scope with its ownIRedbServiceon every call, for code that runs without an exchange and may run in parallel: an observer, a background job, a store call made outside a route step. Without an exchangeGetRedbService(name)hands every caller the same registered instance, one connection, and parallel explicit calls on it are refused by redb's command gate. The caller owns the returnedRedbScopeand disposes it (await using).- Named: the scope factory the host published for the name (Tsak does, one per named instance), otherwise a
scope opened from the instance registered with
RegisterRedbServicethroughIRedbScopeSource(redb.Core). - Default: a scope opened from the service registered on the context, otherwise a scope of the context's service provider.
- A service built without a DI container has no scope to open: the call throws
InvalidOperationExceptionrather than hand out the shared instance. Single-threaded start-up code (scheme sync, seeding) keeps usingGetRedbServicewithout an exchange. - A scope opened inside an ambient transaction takes part in it; work that must survive the route's rollback opens
its scope under a suppressing
TransactionScope.
- Named: the scope factory the host published for the name (Tsak does, one per named instance), otherwise a
scope opened from the instance registered with
Guide: Access IRedbService from Routes — which
IRedbServiceroute code gets, with and without an exchange, and inside a transaction. Theredb.Route.CoreREADME samples are corrected: they called aRedbIdempotentRepository(redb)constructor and anexchange.GetService<IRedbService>()method that do not exist and imported the wrong namespace; the repository is registered by name withAddRedbIdempotentRepository, route steps useProcessWithRedb.
Fixed — an unhandled failure is a failure everywhere: transaction and ack
- A route that fails through an
OnExceptionwithoutHandled(true)returns normally with the exception left on the exchange.TransactedProcessortook that return for success: the deferred broker actions were committed and theTransactionScopecompleted with the work half done — for example a.Transacted()body that calls a sub-route (direct://) whose ownOnExceptionhas noHandled(true). The processor now reads the exchange's terminal state after the body: an unhandled exception rolls the deferred actions back and leaves the scope incomplete (rollback), exactly like a throw; a handled one is a completed exchange and commits. - The same failure was acknowledged by the broker consumers that settle by "did Process throw":
RabbitMQ, AMQP 1.0, Kafka (single and batch commit), IBM MQ (poll and XMS engines), Redis Streams
and MQTT. Each consumer now rethrows an unhandled failure left on the exchange
(
IExchange.ThrowIfUnhandledFailure()inredb.Route.Core) so its existing failure path takes over: nack-requeue, release, no offset commit, backout, no XACK, no MQTT ack. Azure Service Bus and SQS already read the terminal state and are unchanged. - AMQP 1.0:
AmqpAckActionnow tracks whether the delivery is settled. Inside.Transacted()the transaction accepted the message and the consumer's inlineAutoAcceptaccepted it a second time (an error on an already settled delivery); the inline path now settles through the same action and skips it once settled, on the accept and the release side alike.
Fixed — a branch's DI scope no longer reaches the original exchange through the merge-back
- Multicast, Splitter, Scatter-Gather and Loop (
copy) merged the aggregated clone's properties into the original exchange wholesale, including the clone's own bookkeeping: a named redb scope cached under__redb_scope:*by a step of the branch, or a resource registered withExchangeResources. The clone released those right after the merge, so the original kept a disposed scope under the same key and the next redb call on that name failed withObjectDisposedException(a registered resource was released twice). The merge-back now skips what the clone owns, asClone/CreateChildalready do on the way in: the exchange that created a scope or registered a resource is the one that releases it.
Fixed — the editor validated against the schema without the package elements
- The extension's OASIS catalog bound the namespace
urn:redb:route:1.0to the registry-onlyredb-route-1.0.xsd, while the catalog-aware schema that knows the package contributions (<redb>,<redbSave>,<cache>, ...) and the typed endpoint options shipped beside it, unbound. A project with its own.vscodebinding (the scaffold writes one) was fine; any other document got «Element name 'redb' is invalid» from the editor while the loader and the pack gate accepted it. The catalog now binds the catalog-aware schema, and a unit test asserts that the bound file declares the package elements.
Added — <messageHistory> in the markup
- The route-level
MessageHistory()flag of the C# DSL now has a markup form: the leaf step<messageHistory [value="false"]/>, the way<streamCaching/>spells its own flag. Found missing while writing the SerialNumbers demo as XML — a route that wants the per-step timing in its failure log had no way to say so in markup. Schema, element list, editor palette and the generator's C# output carry it.
Removed — the <beginRedbTransaction> markup element
- The route markup no longer has
<beginRedbTransaction [storage=]/>. The verb it printed,BeginRedbTransaction(), is obsolete (TRANSACTIONS.md: one primitive,.Transacted()) and a no-op under an ambient scope — and markup has no warning channel, so an author writing it inside<transaction>got a silent nothing. The loader now rejects it as an unknown element with the position; wrap the steps in<transaction policy="…">, the markup form of.Transacted(). The schema, the element list and the editor palette are regenerated without it. Shipped in 4.0.0 for four days; no package in the wild uses it.
Fixed — build-time route packaging actually sees the runtime
redb-route-xml newscaffolds a library project, and a library does not copy its NuGet assemblies tobin/— so the opt-inPackRouteOnBuildtarget handed the gate an output with no connectors and no markup contributions, and a<redbSave>failed the XSD check for no visible reason. The template now setsCopyLocalLockFileAssemblies; the XmlDemo project gets the same, plus theredb.Route.Corereference its<redb*>elements need and the package namexmldemothe worker already knows it by.redb-route-xmlrefuses a--binwhoseredb.Route.Xml/redb.Routeassembly version differs from the one the tool was built with. Contributions and components of another runtime version cannot be cast to the tool's own interfaces; before, that read as «0 contributions» and a misleading unknown-element error (a 3.7.3 tool over 4.0.0 bins). Update the tool to the runtime's version.
Fixed — error handling now matches Camel (behavioural)
OnExceptionwithoutHandled(true)no longer swallows the failure (redb.Route). When an exception matched a handler that did not setHandled/Continued, the processor marked the exchangeExceptionHandled = trueand returned — so every consumer (GenericFile,Quartz,As2,Http,Soap,Sqs,AzureServiceBus,Grpc,SignalR), which treatsException != null && !ExceptionHandledas a failure, saw success and acknowledged/committed while the transaction had rolled back. The failure is now left on the exchange (Camelhandled(false)): theOnExceptionroute still runs (logging, DLQ, redelivery), but the consumer sees the failure and does not ack.Handled(true)/Continued(true)clear the exception as before. Reproduced red-before at the consumer-contract level.- The most-specific
OnExceptionhandler wins, regardless of declaration order (redb.Route). Handler selection took the first registered match, so anOnException<Exception>()declared first captured everything; it now picks the most-derived matching type (Camel's nearest-supertype rule), ties keeping declaration order.
Added — string/quarantine conveniences
Sql.DataSource(string)(redb.Route.Sql) — the fluent builder accepted onlyDataSource(IExpression), so the documented.DataSource("main")did not compile. A string overload now matches the sibling connectors'ConnectionFactory(string)..MoveFailed(...)on the local File connector (redb.Route.File) — a file whose processing fails can be quarantined to a directory (as the remote FTP/SFTP consumers already do) instead of being left in place and re-picked on every poll. NewFileEndpointOptions.MoveFailed+ DSLMoveFailed(...); unset keeps the previous leave-in-place behaviour.
Fixed — SQL table bootstrap is dialect-aware
SqlIdempotentRepositoryandSqlClaimCheckRepository(redb.Route.Sql) auto-created their tables withCREATE TABLE IF NOT EXISTS, which SQL Server rejects — soCreateTable = true(the default) threw on SQL Server. The DDL is now chosen from the live connection: SQL Server gets a guardedIF OBJECT_ID(...) IS NULL CREATE TABLEwithNVARCHAR/VARBINARY(MAX); SQLite/PostgreSQL/MySQL keepCREATE TABLE IF NOT EXISTS, with the claim-check binary column typed per dialect (BYTEA/LONGBLOB/BLOB) — the latter also fixes claim-check bootstrap on PostgreSQL, which has noBLOBtype.
Fixed — documentation corrected
redb.RouteREADME Error-Handling showed.Retry(...)/.DeadLetterChannel(...)as top-level route steps with amaxRetries:/initialDelay:signature; they exist only on the.Transacted()scope asRetry(int attempts, TimeSpan delay)/DeadLetterChannel(string). The example and the EIP table now show the correct scoped form.Unmarshal<T>()(redb.Route) documented itself as decoding viaIDataFormatRegistryby ContentType, but is an alias ofConvertBody<T>()(type-converter based). The doc now says so and points toUnmarshal(IMessageSerializer, Type)for format decoding.
Fixed — a poison file no longer blocks the rest of the poll batch
- File consumers (
redb.Route.GenericFile→ localFile,Ftp,Sftp) ran each polled file with no guard around the route: an exception that escaped the route (no matchingOnException, or a handler that rethrew) propagated out of the per-file loop and aborted the whole batch. Every file after the poison one in the listing was left unprocessed, andMoveFailednever ran for the poison file itself — so on SFTP a later file could be reprocessed on a loop while earlier ones sat untouched. The per-file processing now catches the escape, records it on the exchange, runs the failure path (idempotent-key release +MoveFailed) and continues to the next file;OperationCanceledExceptionstill aborts the poll (shutdown).
Fixed — a logger factory registered after construction reaches components
RouteContextwired component loggers only from a factory passed to its constructor;AddService(typeof(ILoggerFactory), …)afterwards left components — and the route compiler'sOnException/statistics loggers — without one, so consumer failures were logged nowhere. Registering a factory now adopts it as the context factory and back-fills a logger onto components already registered (and forward-fills onto those added later).
Fixed — context/builder-level OnException keeps its full configuration
- Builder- and context-level
OnException(redb.Route) forwarded only redeliveries andhandled/continuedto the compiled handler;OnWhen,RetryWhile,OnRedelivery,OnPrepareFailure,RetryAttemptedLogLevel/RetriesExhaustedLogLevel,LogStackTrace,LogExhausted,OnExceptionOccurredandUseOriginalBodywere silently dropped — so evenHandled(true)still loggedRetries exhausted … after 0 attempts. The compiler now forwards the full configuration, matching the in-routeOnExceptionpath.
Added — poll backoff for file consumers (File / FTP / SFTP)
GenericFileConsumergains Apache CamelScheduledPollConsumer-style poll backoff. New optionsBackoffMultiplier/BackoffIdleThreshold/BackoffErrorThreshold(with matching DSL methods on File/FTP/SFTP): after that many consecutive idle or error polls, the nextBackoffMultiplierpolls are skipped — no listing and, for remote transports, no reconnect — then the counters reset. A down SFTP server or an empty directory no longer forces a poll (and a reconnect attempt) everyDelay. Entering backoff logs one warning, resuming logs one info line;BackoffMultiplierrequires at least one threshold and vice-versa, or the endpoint refuses the configuration.BackoffOnFailedExchanges(off by default — a superset of Camel) additionally counts a poll whose every created exchange failed unhandled as an error, so a route backs off when the downstream is down and every file fails, instead of re-reading the same files everyDelay; a poll with any success still counts as success.
Fixed — idempotent consumer and redb transactions under .Transacted()
IdempotentConsumerno longer risks a silent-loss window inside a transaction, and no longer masks the original failure. Under an ambient transaction it does notRemovethe dedup key on failure — the rollback removes it, and aRemoveissued inside a doomed transaction would throw and hide the real error. Outside a transaction, aRemovethat itself fails now keeps the original exception instead of being replaced by the cleanup error.BeginRedbTransaction()is[Obsolete]and a no-op under.Transacted(). With core enlisting redb in the ambientTransactionScope, opening a separate redb transaction there is rejected; the method now attaches without opening one when an ambient scope is active, and only opens its own when none is. Use.Transacted().
Changed — behavioural
- An external tool does not run inside a transaction. A tool declared
ToolSideEffect.Externalthat the model calls while an ambient transaction is open (a route's.Transacted()) is not dispatched: the model receivesexternal_in_transaction(ToolResultErrors.ExternalInTransaction) and observers see the same skip reason (ToolSkipReasons.ExternalInTransaction). Its action could not be rolled back with the transaction, and a retry after a rollback would have repeated it. Mutating and read-only tools run inside the transaction as before, so their writes and deferred sends roll back with the route. The budget of an agent run is now recorded outside the route's transaction, so a rollback no longer un-counts spent tokens; on SQLite that second writer waits for the transaction's write lock and fails, sollm:with the redb budget store does not belong inside.Transacted()there. A shadow run no longer inherits the transaction. - Thinking is kept and handed back. Both connectors used to discard a model's thinking: Anthropic
thinking/redacted_thinkingblocks and DeepSeekreasoning_contentwere dropped on parse and never sent back. They now travel with the turn that produced them, within a run and, with a conversation store, across runs, so a stored turn holds more data than before. A message that says nothing (no text and no tool call, such as a turn the model spent entirely on thinking) is kept in the record but not sent to the provider, on the Anthropic and the OpenAI-compatible path alike; the OpenAI-compatible path used to send it ascontent: null, and a refused message in the history breaks every later call of a conversation (2026-09-07).RedbConversationStorethrows on a content block type or a stored kind it has no form for, where it used to write the block'sToString()as text and read an unknown kind back as text. - Claims are enforced.
.RequireClaim(...)now denies the call when the claims cannot be verified; previously the declaration was never consulted. Claims are checked against the caller's principal taken from the exchange (a transport-set property — a client-supplied identity header is not evidence), so a run with no inbound request, or one carrying an unauthenticated principal, is denied. Hosts with their own identity vocabulary register their ownIToolClaimsSource. - Idempotency is gated on the declared side effect. A tool that is not
ToolSideEffect.ReadOnlyis reserved before it runs and a replayedtool_useid returns the stored result; read-only tools are no longer reserved at all. Consequence: replay protection is no longer the default for tools that declare nothing, becauseSideEffectdefaults toReadOnly— declareMutatingon anything that changes state. - Tool-result cache is wired.
ToolCachingPolicy.Memoizeis answered by a run-scoped in-process memo,PersistbyIToolCacheStore(24 h TTL); a hit is a skipped invocation reported to observers ascache_hit. The key includes the resolved endpoint address (hashed, never stored raw), so two tenants with the same input never share an entry, and the tool's policy fingerprint, so entries written while a tool was laxer are not served after it got stricter. Only read-only tools may declare a caching policy — the route build rejectsCaching != Nonewith a mutating side effect — and an output the redaction filter changed is never cached. - Budgets are reachable and cost is real. The three budget options reach the engine, and cost
comes from
ICostCalculatorinstead of a placeholder zero. A cost ceiling nobody can price fails the run before the first provider call instead of being silently ignored. AddRedbLlmStorage()now replaces only the defaults this package ships (IBudgetEnforcer,IAgentObserver); a host-registered implementation survives untouched. It also swaps the shipped per-run budget enforcer forStoreBudgetEnforcer, so a conversation's accumulated usage can stop a run that used to pass.- The cache key carries the caller. A persisted entry is now keyed by tool name, resolved endpoint, policy fingerprint, the caller's identity and the values of the headers the route opted into propagating — not by the input alone. A tool route sees the real principal and the real headers, so "same input" was not "same answer": before this, an entry fetched for one caller could be served to another (same rights, different person).
- The read-only caching rule is enforced at descriptor registration, where every path converges —
.AsLlmTool(...),LlmTool.Define(...).Build(),[ExposeAsLlmTool]and MCP discovery — and MCP server options validate it when they are built. A non-read-only tool that declares a caching policy now fails registration (loudly, before any run) instead of being silently cached by paths that only the route DSL guarded. - Cache access is best effort. A store that cannot be read or written logs a warning and the tool still runs; previously a store outage failed the whole run, and a failed write turned a successful tool call into an error the model could retry.
- Cache entries and audit rows name their tool. A persisted cache key starts with the tool name
(
persist:<tool>:<hash>) instead of being a bare hash, and an audit row's name leads with it (audit:<tool>:<exchange>:<tool_use>). Both are labels: they let an operator read the scheme and slice it by tool on an object column (_objects.name/value_string). Filtering by theToolNameproperty is server-side too (the props-typedWhere) but resolves through the values table, so the label is the cheap path, not the only one. The hash still covers every security-relevant input; entries written in the previous format are missed once. stream=truewithtools=now throwsArgumentExceptionlike the other option checks inLlmEndpointOptions.Validate(), instead ofInvalidOperationException.stream=truetogether withtools=is rejected when the endpoint is created instead of silently ignoring the tools (streaming bypasses the agent engine, so its tools would never run). Streaming without tools is unchanged.
Fixed
llm://endpoints could not see the engine the package registers. The endpoint resolved the agent engine through the route context's own locator only (IRouteContext.GetService<T>()deliberately has no fallback), whileAddRedbRouteLlm()registers the engine in the container — so a host that did everything right gotInvalidOperationException: No IAgentEngine registeredon every call. Producers, consumers and the inline step now shareAgentEngine.FindRegistered(context)(locator, then container), which is also what made the "engine in the container only" tests turn green.- The
No IAgentEngine registeredmessage advisedcontext.AddService<IAgentEngine>(new AgentEngine()): no such overload exists (AddServicetakes(Type, object)), and a bare engine has no producer template (a tool call fails) and no claims source (a requirement is never verified). Both messages now nameAddRedbRouteLlm()andAgentEngine.FromContext(context), and say what a bare constructor costs. - The engine resolved from DI was not the engine the package wires.
AddRedbRouteLlm()registeredAgentEngineby type, so the container picked a constructor it could satisfy — one whose parameters all have defaults — and the resolved engine had no producer template (every tool dispatch failed) and none of the governance seams this release advertises: claims, cache and cost were wired only in tests, which build the engine by hand. The registration is now an explicit factory overAgentEngine.FromContext(...). The inline.Llm(...)fallback had the same hole (?? new AgentEngine()), and the shipped demos plus the demo host assembled the engine by hand, silently losing every seam added since — they now register their deliberate choices and let the factory build the engine (Llm.HttpShellstays on the released surface until the package carriesFromContext; a note in the file gives the exact replacement). ToolAuditPropsdocumented thatWhereRedbpredicates onConversationId,ToolName,OutcomeandInvokedAtUtcavoid the value table. Properties are not object columns, so predicates on them resolve through_values: server-side, but costlier than an object-column filter. The doc now says exactly that, and points at the row name as the cheap path for tool-scoped queries.- Budget option docs promised that a zero ceiling "stops the run at once"; the enforcer has always
enforced only positive limits, so
0means "no ceiling" — the docs now say that, and a test pins it. - MCP discovered tools declare
RequiresApproval = trueby default (external side effects). With the shippedAutoApproveGatethis buys an audit row, not a blocked call. SkipReasonvalues:idempotent_cache_hit→idempotency_hit; the approval refusal is nowdenied: <reason>built fromToolSkipReasons.ApprovalDeniedPrefix, and the model-facing error code isapproval_denied.ToolSetHashsemantics changed: the hash of the tool surface now includes safety (side effect, caching policy, cost class, approval flag, and claims — length-prefixed so["a,b"]and["a","b"]cannot collide). Hashes computed before this release are not comparable with hashes computed after it; re-baseline audit queries that compare them.- MCP safety-override patterns are compiled when the override is built, so an invalid regex fails at configuration time instead of surfacing as "the server is silently absent from the tool set"; an override that matches no discovered tool is reported as a warning, with an example of the model-facing name it most likely copied.
Removed
- Metric
redb.route.llm.tool_cache.expired— an expired entry is indistinguishable from a miss throughIToolCacheStore.GetAsync, and the shippedMemoizelayer does not touch the store at all. The counter was never incremented. Cache stores no longer report hit/miss metrics either: the agent loop does, because it is the only layer that sees both the store and the run-scoped memo.
Added — the caller's identity on the exchange
Nothing could bring an identity to a route over HTTP, gRPC, SOAP or AS2. The consumers ignored who a
request came from, and a trusted middleware had no channel to say it: a redbHttp.* header it added is
dropped by the anti-spoofing guard, and any other header cannot be told apart from one the caller sent.
WebSocket and SignalR had their own Authenticate hooks, but those reduced the principal to a user-id
string.
ExchangePrincipal(core,redb.Route.Abstractions) — the caller'sClaimsPrincipalas an exchange property underCamelAuthentication(Camel'sExchange.AUTHENTICATION). A property rather than a header, so a caller cannot send it and producers do not bridge it on. Every exchange copy (child, linked child, clone, snapshot) inherits it, so a split part, a sub-route or an LLM tool call sees the identity of the request that started it. A route that validates credentials itself records the outcome withExchangePrincipal.Set.HttpHostingOptions.ResolvePrincipal— one resolver for every listener in the process, installed like the trusted proxies: after proxy resolution, so it sees the client rather than the proxy, and after CORS, so a preflight never reaches it. It identifies and does not authorize:nullserves the request without an identity, because one port carries routes with different requirements. A resolver that throws fails the request with 500 and an error log instead of downgrading the caller to anonymous. The result is kept inHttpContext.ItemsunderSharedHttpServerManager.PrincipalItem(SharedHttpServerManager.GetResolvedPrincipal).- The HTTP, gRPC, SOAP, AS2 (receive and async MDN), WebSocket and SignalR consumers put the principal on
every exchange they build. On WebSocket and SignalR the component's own
Authenticatestill decides on its paths and takes precedence; without it, the host's principal fillsredbWs.UserIdand SignalR'sUserIdentifierthe same way. SharedHttpServerManager(options, logger)— a new constructor overload. The listener's own logging is switched off, so this logger is where resolver failures go;AddRedbRouteHttpHosting()passes the host's logger factory.
Additive: with no resolver configured, the host pipeline and every exchange are unchanged.
Fixed — AS2 no longer reports an unsigned message as signature-verified
redbAs2.signatureValid on a received message is documented as "the inbound signature verified against
the partner cert", but the consumer started from true and overwrote it only when the message was
signed. An unsigned message in a partnership that does not require a signature therefore reached the
route reporting a verified signature, and a route that gates on the header accepted it as authenticated.
The header is now true only when a signature was present and verified — the rule the MDN side already
followed, starting from false. Rejection is unaffected: the consumer's own enforcement never read the
header, and the MDN it returns does not depend on it. Behavioural for a route that reads the header on
an unsigned flow: it now sees false.
Changed — behavioural — SQL batch writes
The batch mode of redb.Route.Sql (batchSize above zero with a list body) no longer reports writes that did not
happen. Measured on PostgreSQL, SQL Server and SQLite: continuing past a failed item committed nothing on PostgreSQL
while the exchange succeeded, and on SQL Server wrote the following items outside the transaction after an error that
had ended it.
breakBatchOnErrordefaults totrue, as in Apache Camel: the first failing item rolls the whole batch back. Migration: setbreakBatchOnError=false(or.BreakBatchOnError(false)) to keep going past failed items.- The provider's exception is thrown as is instead of
AggregateException, soOnException<DbException>andcatch (SqlException)match it; the failed item's index is inexception.Data["redbSql.batchFailedIndex"]and in theredbSql.batchFailedIndexheader. Migration: catch the provider's exception instead ofAggregateException. breakBatchOnError=falseruns each item under a savepoint: a failed item is undone and listed inredbSql.batchErrors, and the other items commit. It is refused before any write inside a transacted route or on a provider without savepoints, and it stops the batch with the item's own error when the server has ended the transaction.- An empty list writes nothing (
redbSql.updateCountis0); it used to run the statement once with NULL parameters. - A
${...}value for a numeric or enum SQL option fails endpoint creation (for exampleBatch(Header("n"))); it used to be dropped silently, which turned batching off. - A
@nameplaceholder with no value is an error, as in Apache Camel ("Cannot find key ... to use when setting named parameter"):InvalidOperationExceptionnaming the parameter and the statement, in a batch with the item's index. It used to bindNULLsilently. Applies tomode=Execute(single statement and batch) and to the poll consumer'sonSuccess/onFailure/onBatchComplete. A value that is present still bindsNULLwhen it is null: a header or key set tonull/DBNull.Value, an empty string, aparam.*expression that evaluates to null.@redbErroralways has a value (NULLwhere nothing failed). Migration: give the placeholder a source —param.name=..., a header, or a key — or set it tonullexplicitly. - JSON values bind as scalars: a
JsonElement/JsonNodein a header, a dictionary body or a batch item becomes a string,long/decimal/double,boolorNULL, and a JSON object or array becomes its JSON text. It used to reach the provider as is and fail (No mapping exists from object type JsonElement). - The body of a single statement binds by key when it is a map, as in Apache Camel: any dictionary (a CSV row
Dictionary<string, string>, read-only and non-generic dictionaries) or a JSON object (JsonElement,JsonDocument,JsonObject), with the key's case as a fallback. OnlyIDictionary<string, object>used to be read. A POCO, XML or JSON-text body is still not read by name. batchSizeis the number of statements in one round trip, not an on/off flag: on PostgreSQL and SQL Server a batch of 10 000 items withbatchSize=500goes as 20DbBatchround trips instead of 10 000 commands. The whole batch still commits or rolls back as one transaction;redbSql.batchStrategyreadsDbBatchthere instead ofCommands. Migration: none; a very largebatchSizemakes one very large request.- Sequences, streams and JSON arrays are batch sources, not only lists: with
batchSizeabove zero anIEnumerable(HashSet, LINQ,yield), anIAsyncEnumerableof a reference type (aStreamListresult) and a JSON array (JsonElement,JsonDocumentwith an array root,JsonArray) are written item by item, read as they are written — a stream of millions of rows never sits in memory. They used to run one statement with the whole body. A string, a dictionary or JSON object and an XML document stay one value. Migration: none, unless a route relied on such a body reaching a single statement: setbatchSize=0there.
Added — SQL batch results
SqlHeaders.BatchStrategy,BatchItemCount,BatchFailedIndex,BatchErrors; theSqlBatchItemErrorrecord (index, message, SQLSTATE);SqlBuilder.BreakBatchOnError(bool).DbBatchfor SQL batches: withbreakBatchOnError=trueon a connection that can create aDbBatch(PostgreSQL, SQL Server), items go in chunks ofbatchSizestatements, one round trip each;redbSql.batchStrategyisDbBatchandSqlHeaders.BatchChunkCountcounts the round trips. The failed item's index comes from the provider'sDbException.BatchCommand; a provider that does not fill it gets the chunk's first item andexception.Data["redbSql.batchFailedChunk"]with the chunk's range. Elsewhere one command is created per batch and its parameters are reused for every item. No option picks the strategy, as in Apache Camel.pollDelivery=List(SqlEndpointOptions.PollDelivery,SqlBuilder.PollDelivery; Apache CameluseIterator=false): a poll consumer delivers one exchange with every polled row — a list, or withoutputType=StreamListthe open stream — and runsonSuccess/onFailureandonBatchCompleteonce for it, after the reader is closed. The defaultPerRowkeeps an exchange per row.
Fixed — SQL batch
- Cancelling a batch no longer records the cancellation as an item error and goes on executing the remaining items.
- A
byte[]body withbatchSizeset is one value, not a list of bytes inserted one by one. - Batch items bind their own values whatever their shape:
Dictionary<string, string>(CSV rows), read-only and non-generic dictionaries, JSON objects (JsonElement,JsonObject,JsonDocument), POCOs of the application (by property,[Column]orsnake_case), and exchanges grouped byAggregationStrategies.GroupedExchange()(their own headers). These used to bindNULLor the carrying exchange's headers. Keys differing only by case are no longer resolved by whichever came last. XML nodes and other .NET types (XElement,Uri,Stream) carry no named values, as in Apache Camel: bind them with expressions such asparam.id=${xpath('@id')}. - A
param.*expression of a batch item sees the exchange properties and route context (${property.x}); it used to be evaluated on a bare exchange.
Fixed — a streamed SQL result releases its connection when the exchange ends
outputType=StreamListhanded the route anIAsyncEnumerablethat closed its reader, command, transaction and connection only when read to the end. A stream the route did not read — the route failed, filtered the message, or replaced the body — kept its connection until the garbage collector found it; on PostgreSQL its open transaction also held a lock on the table (aDROP TABLEwaited until timeout). The result is now registered with the exchange, as Apache Camel closes the result set on exchange completion, and released when the exchange ends. It can be read once; a second read fails with an explicit message.
Changed — behavioural — StreamList inside a route transaction
outputType=StreamListis refused inside a transaction (a transacted route, an ambientTransactionScope), before any connection is opened. Measured on PostgreSQL and SQL Server: the transaction does not commit while the stream's reader is open (TransactionInDoubtException), and a later SQL step in the same transaction would need a second connection and a distributed transaction. Migration: useoutputType=SelectListinside the transaction, or read the stream outside it.
Added — exchange resources
ExchangeResources.ReleaseWithExchange(exchange, resource)(Apache Camel:addOnCompletion): a component registers anIAsyncDisposablethat the exchange releases inReleaseScopes/DisposeAsync, most recent first and before its DI scopes. Copies of the exchange do not inherit registered resources, andRemovePropertiesby mask keeps them.
Fixed — streaming SQL poll marks its rows
- A poll consumer with
outputType=StreamListranonSuccess/onFailureon the connection whose reader was still open, with or without a transaction. PostgreSQL and SQL Server refuse a second command there; the failure was swallowed without a log, the rows stayed unmarked and were processed again on every poll (SQLite allowed it, so the tests passed). Outside a transaction the statements now run on a connection of their own to the primary database, as Apache Camel runsonConsumethrough itsJdbcTemplate; inside one they stay on the reader's connection and transaction, as in Camel. A failed row of a transacted streaming poll rolls the transaction back after the reader is closed; PostgreSQL and SQL Server refuse even the rollback while it is open, and the poll used to throw from there. - A failing
onSuccessis logged in every poll mode; it used to pass unnoticed when noonFailurewas set.
Changed — behavioural — streaming poll on SQLite outside a transaction
- SQLite,
mode=PollwithoutputType=StreamListandonSuccess/onFailure, no transaction: the statements now run on another connection, and in SQLite's default journal mode the open reader blocks that write ("database is locked"). It used to work on the reader's connection. Migration: settransacted=true(the statements stay on the reader's connection), switch the database to WAL, or dropoutputType=StreamList. - The SQL connector README no longer calls
outputClass/outputHeaderunimplemented orSql.Procedure(...)broken.
Fixed — SQL outputType=Auto and named queries
outputType=Autofollows the statement that runs, not the URI path. ASELECTgiven in thequeryoption (a constant or${...}) or through a named queryref:nameran as a non-query: the body stayed as it was,redbSql.updateCountwas-1, and nothing failed. ASELECTpath with anINSERTinqueryran as a reader. Behavioural: such a route now gets the rows in the body (orredbSql.updateCountfor theINSERT).sql:ref:nameworks with named queries registered throughAddRedbRouteSql(sql => sql.AddNamedQuery(...)). The registry was never put where the connector looks for it: the producer threw "no ISqlNamedQueryRegistry is registered" and a poll consumer failed that way on every cycle. The registry is now registered on the route context.
Changed — behavioural — SQL outputClass maps strictly
- A column that cannot become its property fails the exchange with an
InvalidOperationExceptionnaming the column, the property and both types, as Apache Camel'sBeanPropertyRowMapperrefuses a type mismatch. It used to be skipped silently, leaving the property's default. Measured on SQLite, PostgreSQL and SQL Server:timestamptz/datetime2intoDateTimeOffset,dateintoDateOnly, an integer into anenum,5000000000or'abc'intointall came out as defaults, with no error. The accepted conversions are listed in the connector README;ScalarMapper<T>follows them too. NULLinto a value-type property is an error; into a nullable or reference property it setsnull(it used to leave whatever the property's initializer set).- A timestamp without a time zone does not become a
DateTimeOffset, and aDateTimeOffsetdoes not become aDateTime: the offset would be guessed or lost. - Text is parsed with the invariant culture:
'12.5'intodecimalfailed under a culture with a decimal comma. - An error while mapping
outputClassreaches the route as itself; the mapper was invoked by reflection per row and every exception arrived wrapped inTargetInvocationException. - Migration: make a property nullable where its column is, map timestamps without a time zone to
DateTime, and give a property a type that holds its column's values.
Changed — behavioural — SQL read replica only with readOnly=true
readOnly=true(SqlEndpointOptions.ReadOnly,SqlBuilder.ReadOnly()) sends a statement to the data source's read replica (SqlConnectionOptions.ReadConnectionString), formode=Execute,mode=Procedureand a poll consumer. Without it every statement goes to the primary database. The replica used to be picked automatically: anExecuteendpoint whoseoutputTypereads rows —INSERT … RETURNINGwithoutputType=Scalarincluded — and every poll consumer, itsonSuccess/onFailureincluded, ran on it. Only the endpoint's author knows that a statement does not write (aSELECTcan call a function that writes, advance a sequence or take a lock), so nothing is guessed from the SQL.readOnly=trueis refused withbatchSizeabove zero, and for a poll withonSuccess/onFailure/onBatchCompleteortransacted=true: rows read on a lagging replica and marked on the primary come back.- Migration: add
readOnly=true(or.ReadOnly()) to the endpoints that should read from the replica.
Fixed — SQL execution time
redbSql.executionTimemeasured only resolving and binding the statement: the stopwatch stopped before the command ran. It now includes executing the statement and reading its result — foroutputType=StreamList, until the reader is open — in the producer and in the poll consumer.
Added — SQL batch keys and telemetry
redbSql.generatedKeysfor a batch, as Apache Camel'sCamelSqlGeneratedKeyRows: with anoutputTypethat reads rows (SelectList,SelectOne,Scalar,StreamList), the rows the batch's statements return —INSERT … RETURNINGon PostgreSQL and SQLite,OUTPUT inserted.*on SQL Server — are collected in item order, acrossDbBatchround trips, asList<Dictionary<string, object?>>(List<T>withoutputClass);SqlHeaders.GeneratedKeysRowCountholds their number, and the body stays the list that was written. A failed item of a batch that goes on past errors returns none.outputType=Automakes anINSERTa statement without rows, so nothing is collected. MySQL'sLAST_INSERT_ID()and Oracle'sRETURNING … INTOare not collected in a batch.- Batch tags on the
sql.executespan:db.operation.batch.size(from two items, as the OpenTelemetry database conventions count a batch),redb.sql.batch.strategy,redb.sql.batch.chunks(DbBatchround trips) andredb.sql.batch.failed_index. Parameter values are never tagged.
Changed — breaking — SQL parameters are written :#name, as in Apache Camel
- A parameter in a
sql:statement is:#name: in the URI path andquery, inonSuccess/onFailure/onBatchComplete(:#redbErrorthere), in named queries, in batches and in the poll query.@is no longer a placeholder and reaches the database as written, so T-SQL variables (DECLARE @n int = :#value),EXECargument names (EXEC proc @arg = :#value) and MySQL user variables work. They clashed with@nameplaceholders: with strict bindingEXEC proc @arg = @valuefailed with "has no value", and a declared variable never worked. - Placeholders are found only in the SQL itself, not inside string literals, quoted identifiers, comments or
PostgreSQL dollar quotes:
'user@example.com'and-- :#todostay text. placeholderStyle(SqlEndpointOptions.PlaceholderStyle,SqlBuilder.PlaceholderStyle(...)) sets what the provider receives:At—@name, the default (Npgsql, SqlClient, Microsoft.Data.Sqlite, MySqlConnector);Colon—:name(Oracle);Question—?with one parameter per occurrence (ODBC, OleDb).backslashEscapes(SqlEndpointOptions.BackslashEscapes,SqlBuilder.BackslashEscapes()): MySQL and MariaDB escape a quote with a backslash by default, and'it\'s'used to hide the placeholders after it — the provider got:#idand refused the statement. WithbackslashEscapes=truea backslash inside'…'and"…"escapes the next character. Off by default: in standard SQL'C:\'is a complete literal, and nothing is guessed from the provider. Doubling the quote ('') works without the option. Checked on MySQL 8.4, MariaDB 11 and Firebird 5 (tier 2).- A placeholder of the poll query without a
param.*value is an error naming it, as everywhere else: the poll query has no exchange to take values from. SqlBuilder.Param("@x", ...)is refused: name the parameterxor:#x.- Apache Camel's inline expressions
:#${...}and:#in:namelists are refused with a message; useparam.name=${...}. - Migration: replace every
@nameplaceholder with:#nameinsql:URIs, lifecycle SQL, named queries and Route-XML; keep@only where the database itself means it.
Fixed — SQL connector review after 4.0.0
A code review of everything the SQL connector gained after 4.0.0, with Apache Camel as the reference. Every finding is fixed; the ones that change behaviour are marked.
- A streamed result survives copies of its exchange. The body of
outputType=StreamListwas disposable, and a copy of the exchange (WireTap, RecipientList, Loop, Threads, aseda:hand-off) shares the body: disposing the copy closed the reader and the connection under the original — deterministically after.Enrich("sql:…?outputType=StreamList"). The body is no longer disposable; the release is a resource of the exchange that ran the endpoint, andEnrichhands the resource exchange's resources over to the enriched one (ExchangeResources.HandOver, as Camel'shandoverCompletions), so the enriched route reads the stream. A copy that outlives the original still finds the stream released: read it in the segment that produced it, or useSelectListacross aseda:/Threadsboundary. SqlIdempotentRepositoryandSqlClaimCheckRepositorybootstrap on MySQL and MariaDB. TheirTEXTkey columns were refused in a primary key (error 1170), soCreateTable=truefailed on the first call. The keys are sized now —VARCHAR(128)for the processor name,VARCHAR(255)for the message and claim keys,NVARCHARof the same sizes on SQL Server, where the idempotent key also drops under the 900-byte clustered index limit (it was 1020). Tables created by an earlier release keep their schema.SqlIdempotentOptions.TableNameis validated like the claim-check one.- Behavioural:
outputType=Autois decided by what the statement returns, as Camel'sexecute()asks the driver — not by the first word of the text. A comment beforeSELECTor a leading(used to run the statement as a non-query (body untouched,updateCount=-1, no error);WITH … UPDATEran as a reader.INSERT … RETURNINGunderAutonow returns its rows; a batch underAutocollects no rows, as Camel'sexecuteBatch.redbSql.outputTypereports the resolved type. - Behavioural: lifecycle SQL of a poll (
onSuccess/onFailure/onBatchComplete) binds values under the producer's rule:""bindsNULLand aJsonElementbinds its scalar. They used to reach the provider as they were — a JSON header failedonSuccess, the row stayed unmarked and was polled again forever. - Behavioural: a stop is not a failure of the row. A cancellation of the poll while a row was in the route ran
onFailurewith the cancelled token, marked the row with "The operation was canceled" where the driver let it, and logged an error. The cancellation now goes up,onFailuredoes not run, nothing is logged as an error, and the row is polled again after the restart. Rollbacks no longer take the cancellation token. - Behavioural:
param.x=${…}andquery=${…}on a poll are errors naming the option: a poll has no exchange to evaluate them in. The parameter used to bind the template text as its value (an empty result, silently), and the query fell back to the URI path. - Behavioural: a batch reports only its own run.
redbSql.batchFailedIndex,batchErrorsandredbSql.errorleft by an earlier attempt on the same exchange (a redelivery throughOnException, an earliersql:step) are removed before the batch writes its result.redbSql.batchStrategyis set on failure too. - Behavioural: a returned row that
outputClasscannot hold ends the batch withSqlRowMappingException(anInvalidOperationException) in either error mode; in continue mode it used to be recorded as an error of every item, in break mode as a chunk failure with the chunk's first index. :#Idand:#idin one statement are one parameter in the text the provider gets (SQLite refused the second name).boolandenumproperties accept adecimalordoublethat holds an integer — OracleNUMBER(1), PostgreSQLnumeric— as the integer properties already did.- Invalid ISO text (
2024-13-45) into a date property fails as a mapping error naming the column, not with aFormatExceptioncarrying the value. SqlBuilder.Param(name, object)and the numeric builder options are written invariantly:12.5, not12,5under a culture with a decimal comma; dates as ISO 8601 round-trip text.SqlBuilder.DataSource(IExpression)andConnectionString(IExpression)take a constant only; a${…}expression is refused, since the endpoint is fixed when it is created (a dynamic target isToD). They used to write the template text into the URI.SqlBuilder.ConnectionString(string)added.outputHeader=${…}names the header per exchange; it used to be a header literally named${header.x}.asFunction=truewith anOUT/INOUTparameter is refused at endpoint creation:SELECT fn(...)returns the scalar, and under a positional placeholder style the parameters shifted.pollDelivery=ListwithScalar/SelectOneis refused instead of ignored.AddRedbRouteSqlcalled twice keeps the named queries of both calls and registers onesqlcomponent; the second call used to replace the first call's registry.- The message for a placeholder without a value names it
:#name; a sequence of bytes or characters other thanbyte[]/string(ArraySegment<byte>,List<byte>,char[]) is one value, not a batch; a source that answers a cancellation with its own exception is not reported as a source failure; a transaction whose disposal throws no longer strands the producer's connection; a failing release of an exchange resource is logged through the context when the exchange has no DI scope.
Changed — the SQL connector is tested on Microsoft.Data.SqlClient 7.0.3
- The SQL Server e2e suite of
redb.Route.Sqlruns onMicrosoft.Data.SqlClient7.0.3 (was 5.2.2), the versionredb.MSSqlnow ships; the connector itself references no driver. Characterization and the full suite give the same results as on 5.2.2 on net8.0, net9.0 and net10.0.
4.0.0
4.0.0 breaking bundle (docs/V4/09-BREAKING.md). The entries under Removed and the first two under Changed are the major's breaking changes; they ship together with a migration note.
Fixed — a SQL connection is returned to the pool whatever happens next
Two holes in SqlProducer, both on the hot path for audit and usage writes, where the failure
modes that trigger them (dropped sockets, cancellations) come in bursts — so a leak drains the
pool rather than dripping. BeginTransactionAsync ran before the try, so a throw there stranded
the connection with no finally to catch it; it now runs under the same finally as everything
else. And the StreamList branch decided who cleans up by the option value rather than by whether
ownership actually reached the stream: an error before the handoff — the reader failing to open —
leaked connection and transaction both. A flag set only after the handoff decides now, which also
covers the day Auto resolution and the option disagree. The command is disposed on the failure
path too, not only on success.
What StreamList hands over is safe on abandonment as well: the streaming iterators hold reader,
command and connection in await using, so disposing the enumerator — a completed loop or an
early break — releases them. The one case nobody can close is a stream that is never iterated
and never disposed; that is inherent to handing a caller a lazy sequence.
Fixed — releasing an exchange's scopes no longer spends a one-shot latch
Exchange.ReleaseScopes() is public API, and downstream error handlers call it manually; the
first call used to spend the latch for the life of the exchange, so any redb scope cached on the
exchange afterwards — an error handler's tail touching redb again does exactly that — was
invisible to DisposeAsync forever. The latch is now an in-progress guard that re-arms after
every sweep (in a finally, so a throw mid-sweep cannot jam it): concurrent calls still cannot
double-sweep, nothing is disposed twice — entries leave the property bag as they are released and
the owned scope nulls out — and a later call releases whatever appeared since. The internal
PrepareForReplay() workaround, which existed only to re-arm the latch for checkpoint replays, is
gone with the defect it worked around.
Fixed — a request the dispatcher cannot bind is the caller's error, not the server's
All four controller dispatchers (HTTP, gRPC, SignalR, SOAP) funnelled parameter-binding failures —
a JSON body missing required members, a route parameter that is not a guid, junk XML — into the
generic exception handler: the caller got 500 InternalError with a text that hides the actual
problem, and the operator got an Error log with a full stack for every piece of junk POSTed from
outside. The boundary is now drawn around the parameter-resolution step, not around exception
types: the same FormatException thrown inside the action stays a 500 with an Error log. HTTP,
gRPC and SignalR answer 400 BadRequest carrying the binding error's own text and log a Warning
without a stack. SOAP has no status codes, so the dispatcher throws the new
MalformedRequestException (core Abstractions — a transport-neutral "your request is wrong"
signal) and the SOAP consumer maps it to a Sender/Client fault carrying the message, instead
of a Receiver fault whose retry semantics are a lie for bytes that will fail identically
forever. Returning the binding error's text is the BR-4 rule, not a violation of it: it describes
the caller's own bytes, nothing in it is ours to hide. The reclassification covers only the
caller's data (body, headers, route and query values): a SOAP operation whose signature no
request could ever bind — a required simple parameter with no source — is the controller
author's error and stays a Receiver fault.
Added — measurements readable from inside a route (METRICS_IN_ROUTE_PLAN, П1–П3)
IExchange.Context— Apache Camel'sexchange.getContext(). Stamped by an outermost route wrapper on entry and restored on exit, so "current" always means the route actually executing: inside adirect:/vm:sub-route the inner route's context, the caller's own again after it returns, andnullon an exchange never handed to a route. A default interface member, so externalIExchangeimplementers do not break. All five exchange copy paths (Clone,Snapshot,CreateChild,CreateLinkedChild,CloneLinked) carry it. This is what lets a processor read(IEndpointStatistics)e.Context.GetEndpoint(...)without capturing the context into every lambda.stats(target, metric)in the expression language — endpoint statistics as values, so a route can branch on its own measurements:Filter("stats('direct:orders', 'health') != 'Critical'"),When("stats('current', 'cancelled') > 100"). Target is an endpoint URI or'current'; metrics are theIEndpointStatisticssurface by name, case-insensitive, withaverageProcessingTimeMsas a number because the language compares numbers. A typo in a literal metric name fails the route build with the list of known names. Implemented in both engine branches — theformat()lesson — and every failure is loud: a route that asks for measurements it cannot get must not route messages on a silent null.context.UseMetricsSnapshot()— the OpenTelemetry layer readable in-process, opt-in. Theredb.Routemeter is push-only, so the EIP counters (throttle.delayed,circuitbreaker.tripped, …) and.Metered()step durations used to go to a backend or nowhere; the snapshot is an in-processMeterListenerkeeping current values per (instrument,redb.route.id,redb.route.step) — counters as running totals, histograms as count/sum/min/max/last. Deliberately opt-in (a listener sees every measurement — nobody pays unasked), no windows, no percentiles, no history: that is a backend's job. Disposed when the context stops; collected points stay readable.services.AddMetricsSnapshot()for DI,context.GetMetricsSnapshot()to resolve.- The guide for all of it, layer by layer with recipes:
METRICS.md.
Changed — the DeepSeek default base URL follows the current docs
https://api.deepseek.com/ instead of .../v1/, checked 2026-09-10 against the provider's docs
after the V4.1-Flash release: DeepSeek documents the root as its base — unlike every /v1
neighbour in the table — and /v1 survives only as an undocumented legacy alias (it still
answers 401, not 404, so nothing breaks either way; the canonical form is simply the durable
one). Model names were always pass-through, so V4.1-Flash works with no further change; a
self-hosted or proxied setup still overrides via LlmConnectionFactory.BaseUrl. Pinned by
OpenAiBaseUrlTests so a future edit does not "harmonise" it back to /v1.
Changed — LLM storage lookups read the unique key (Ф4, no-migration form)
All 17 key lookups across the redb-backed LLM stores now search ValueUnique == RedbUniqueKey.Normalize(key) instead of value_string — the column the level-A write barrier
converges on. The performance claim, checked live on the MSSQL stend: _value_string has
meanwhile been retyped and indexed by the FK-index work, so both paths seek today — the unique
path is still the tighter one (a unique composite (_id_scheme, _value_unique) covering _id,
guaranteed at most one row) but the honest headline is correctness, not scan-versus-seek. The one
value_string lookup left is the conversation-message find, deliberately: messages carry no
unique key (a message id is only unique within its conversation).
By owner decision there is no migration in any form — no backfill, no lazy adoption. The
consequence is pinned by tests rather than implied: rows written before level A, carrying the key
only in value_string, are invisible to the stores now.
Moving the branch point surfaced two write-path asymmetries the string/unique split used to hide. The batch register's terminal-status guard lived only on the collision path, so a redelivered "submitted" with a matching raw id could downgrade a completed batch to pending forever — a latent bug from before this change; the guard now applies however the row was found. And the template registry's two branches genuinely disagreed (upsert on find, first-wins drop on collision): the old split by raw string is preserved explicitly — re-setting the version you own updates it (the Р6 contract), while a row owning the unique key under a diverged string is a race winner and the incoming body is dropped, because a version is immutable provenance.
Fixed — control-bus stats carry the whole counter surface, escaped
controlbus:route?action=stats is the one DSL-reachable view of endpoint statistics, and its XML
had trailed IEndpointStatistics: Warnings, Rejected, Cancelled, bytesIn/bytesOut and
the last error were readable through a captured context but invisible from a route — exactly the
columns the recent accounting work made honest. All of them are attributes now, lastError
included (present only when there is one). Free-text values — the route id and the error message —
are XML-escaped on the way in; a quote inside an exception text used to corrupt the document for
whoever parsed it downstream.
Fixed — a cooperative cancellation is not a route error (BR-10)
A dashboard closing mid-poll cancels the caller's token; the exchange is abandoned, not failed. The
statistics wrapper still counted it into Errors, so a healthy management route turned red on an
error panel and its endpoint health degraded for the five-minute error window — on nothing but
polling churn. The counter saw what the log did not: HttpConsumer already filters
OperationCanceledException past its logging, and the error-handling layer has always read OCE as
"not a failure" (retry never retries it, the dead-letter channel never parks it, OnException
never handles it). The statistics and lifecycle layers were the last two counting it.
The rule, applied at every counting and event site: an OperationCanceledException while the
caller's token is cancelled is a cancellation; an OCE while the caller's token is live (an
internal timeout) is the route failing to answer in time and stays an error. Four sites:
StatisticsProcessor (both its branches — the rethrown exception and the one parked on the
exchange by a parallel branch), CountedSend (a cancelled send no longer errors the target
endpoint), and ExchangeEventsProcessor (listeners hear the exchange arrive and then hear
nothing — neither completed nor failed — so NotifyBuilder does not count a closed tab as a
failure).
Cancellations are not silently dropped: IEndpointStatistics grows a Cancelled counter
(RecordCancelled()), following Rejected from the concurrency work — with one accounting
difference stated in its doc: Rejected is counted before an exchange exists, Cancelled after
MessagesIn, so a consumer endpoint's completed count is now MessagesIn - Errors - Cancelled.
A storm of cancellations stays visible, in the right column. RecordCancelled bumps activity but
not the last-error stamp, so health stays clean. The interface addition is breaking for external
IEndpointStatistics implementers, in the major where Rejected already was.
Fixed — Mermaid export: the continuation after a branching merges from branch tails
MermaidRenderer drew the step after a choice/tryCatch with a solid edge out of the
diamond itself, so on mermaid.live the continuation read as one more branch (the owner's
side-by-side against the graph editor caught it). The renderer now walks the same way the
visualizer does: one edge per branch TAIL into the continuation, nested branchings
recursively; an empty branch contributes its condition node. The two example goldens with a
post-branching continuation regenerated deliberately (REDB_XML_GOLDEN_REGEN=1), the diff is
exactly the diamond edges replaced by tail edges.
Added — VSCode extension, the route GRAPH editor (Route-XML F8, stages 1–4)
The redb Route Graph custom editor (Open With / the title-bar button): the text stays the
truth, the graph is a projection over a position-exact XML tree — the editor NEVER
reserializes the file. Rendering per the visual language: the main line left-to-right with
arrowed edges, scopes as brackets with the condition on the schema, choice/tryCatch/multicast
as branch stacks the line forks around and visibly rejoins, split with the repeat mark, rich
log as one node, onException as a folded strip, unknown elements as opaque nodes kept
byte-for-byte, secrets redacted in labels, tooltips and the panel alike. A click opens the
properties panel: id/description first, spec attributes with enum/bool dropdowns, endpoint
URIs decomposed into the F4 catalog's typed options (37 schemes; the set ones first,
Sensitive masked and locked), rich-log content editable row by row. All four editing
operations of the plan: «+» slots with the mechanically-assembled palette, Delete, drag to
reorder, drag into/out of a bracket — every edit a surgical one-line-diff text change, a
step's comments travel with its block. Composite nodes fold to a counter square (deep levels
fold by default; the state lives in workspaceState, never in the file). The element list and
the component catalog are GENERATED resources (redb-route-xml elements|catalog); a category
missing for a registry element fails the extension build. 76 unit tests over the pure core
(parser spans, graph projection, text surgery, uri surgery, palette).
Added — VSCode extension, text mode (Route-XML F7)
tools/vscode-redb-route/: completion, validation and enum hints for the XML routes via
Red Hat XML reading the shipped XSD — bound by an OASIS catalog to the NAMESPACE
urn:redb:route:1.0, so a document gets the schema because of what its root says, never its
file name; someone else's route.xml is untouched (the F7 activation contract, with negative
tests). A route tree in the sidebar (ids, from endpoints, click opens the line; comments
and CDATA cannot fake a route), snippets for the everyday constructions, and a Mermaid
command over the redb-route-xml tool when it is installed — everything else works without
.NET. TypeScript, zero runtime dependencies, the sniff and the scan pinned by node:test
units. Live Extension-Development-Host verification is the owner's step.
Removed
Declared-but-never-read factory options, full sweep (the F11 odds-and-ends pass, the Zh-3 audit of all 22 factories).
AmqpConnectionFactory.Reconnect/ReconnectInterval/MaxReconnectAttempts(the component's connection pool already self-heals — a closed connection is evicted and recreated) andMaxLinksPerSession;LlmConnectionFactory.ModelVersionandRetries(the Llm engine's resilience policy is deliberately fallback-to-another-factory, not retry-the-same-provider);SoapConnectionFactory.ProxyType. None of them was ever read. Along the way: AWS credentials in S3/Sqs moved off the deprecatedFallbackCredentialsFactorytoDefaultAWSCredentialsIdentityResolver;S3Dslwas split into three partial files per the "~400 lines" rule; named-factory examples (AddToRegistry+connectionFactory=) were added to the READMEs of 19 connectors, and the resolution contract is documented in the core README.S3EndpointOptions.CustomerAlgorithmandDeleteAfterWrite(F11 wave S-B). The SSE-C algorithm is hardwired to AES256 by the SDK (the option was never read), andDeleteAfterWrite("delete the source file after upload") never had defined semantics for a message-body producer and was never implemented. The DSL'sIdempotent(IExpression)key parameter is gone withIdempotentKey(see below).S3EndpointOptions.IdempotentKey(F11 wave S-A, finding S-3). The implementation wasreturn _options.IdempotentKey; // TODO: expression evaluation— a CONSTANT key for every object, so with the option set exactly one object was processed in the consumer's lifetime and everything else was skipped as a "duplicate". An expression is impossible at this seam by construction (the key is needed BEFORE downloading, the exchange exists only after), and a custom idempotency key already has a first-class home: the route-levelIdempotentConsumer(...)EIP with a real expression. The built-in pre-download filter keeps its honestkey|ETag|sizeidentity.FirestoreEndpointOptions.IncludeMetadataChanges(F11 wave G). Declared, documented — and unimplementable:Query.Listenin the .NET SDK (Google.Cloud.Firestore 3.13) has no metadata-changes overload. The option never did anything; removed instead of pretending.String synonyms with the misleading
Expressionsuffix:LoopExpression(string)(all three overloads),DelayExpression(string),ThrottleExpression(string, period). In the rest of the DSL the suffix means "takes anIExpression", and those overloads (LoopExpression(IExpression, …),DelayExpression(IExpression),ThrottleExpression(IExpression, period)) stay. The string forms live on the verbs themselves now, likeFilter(string)always did — mechanical replace:LoopExpression("${header.count}") → Loop("${header.count}") LoopExpression("${header.count}", sub => …) → LoopExpression(new StringExpression("${header.count}"), sub => …) DelayExpression("${header.backoff}") → Delay("${header.backoff}") ThrottleExpression("${header.rate}", period) → Throttle("${header.rate}", period)The string forms of the
Set*/Transformverbs —SetBodyExpression(string),SetHeaderExpression(name, string),SetPropertyExpression(key, string),TransformExpression(string)(owner decision,docs/V4/09-BREAKING.md§4, option (b) there). One rule across the DSL: theExpressionsuffix means "takes anIExpression", and a${...}template is an expression, so there is one form for it. A plainstringkeeps meaning a literal value — the position decides, the content never does.SetBodyExpression("Hello ${header.name}") → SetBody(Expr("Hello ${header.name}")) SetHeaderExpression("target", "q-${header.t}") → SetHeader("target", Expr("q-${header.t}")) SetPropertyExpression("k", "${header.id}") → SetProperty("k", Expr("${header.id}")) TransformExpression("upper(body)") → Transform(Expr("upper(body)"))Expr(...)isRouteBuilder.Expr, now public and static: inside aRouteBuildersubclass writeExpr(t), from the lambda style addusing static redb.Route.Core.RouteBuilder;or writeRouteBuilder.Expr(t);new StringExpression(t)is the same thing. Before the removal an equivalence grid (fifteen forms across the four positions) proved the string form andExpr(...)already meant exactly the same, type included; it stays as the contract of the surviving form (SetVerbExpressionFormTests). The definitions and processors behind the removed verbs (Set*StringExpressionDefinition,TransformStringExpressionDefinition,StringExpression*Processor) are gone with them: nothing constructed them any more, and they did whatSet*(IExpression)does.Newtonsoft.Jsonis no longer a dependency ofredb.Route. The only consumer wasjpath/JsonPathExpression, which now runs on JsonPath.Net 3.0.2 (RFC 9535) over System.Text.Json — one JSON object model in the core for the serializer, schema validation,jpathand the data formats. Packages that used Newtonsoft transitively must add their own reference (redb.Route.Llm.Toolsdid: itsJsonPathDslkeeps the full Newtonsoft dialect on purpose). A 60-form characterization snapshot (JsonPathDialectSnapshotTests) was recorded on the old engine first; 44 forms are byte-identical, the 16 that changed are listed under Changed below.
Changed
${...}templates render numbers and dates culture-invariant.${header.price}is2.5on every machine (before:2,5on a ru-RU server); dates render ISO 8601 (2026-09-01T10:30:00.0000000Z). A route must not depend on the server locale; locale formatting is an explicitformat(x, 'N2', 'ru-RU'). Strings and booleans are unchanged. This closes the last open item of the expression-language unification (a template with a whole-string${x}still keeps the CLR type).- A
${...}placeholder in a consumer URI failsStart().From("kafka://orders-${header.region}")has no message to resolve against; before, it was silently kept as text. Use{{key}}configuration placeholders or a constant. jpathdialect after the engine change (JsonPathExpression,${jpath(...)},jpath('...')): a date string in JSON stays a string (Evaluate<DateTime>still parses it; before, Newtonsoft auto-typed it and even rendered it by the machine culture); numbers render invariant ("19.95", not"19,95"); single-quoted JSON is rejected (comments and trailing commas still parse); an array or object asked asstringgives compact JSON text instead of throwing;objectresults areSystem.Text.Json.Nodes.JsonObject/ typed arrays /object[]instead of NewtonsoftJObject/JArray; an empty filter result asked asboolisfalseinstead of an exception; negative slices ([-1:]) andJsonNodebodies work; an invalid path fails in the constructor (route build), not on the first message. Everything else — selectors, filters, typed conversions,Int64scalars,Int32[]arrays, null / missing semantics, POCO bodies — is unchanged and pinned by the snapshot.- XPath in a condition asks whether the path matched.
Filter(XPath("/order/discount"))andWhen(XPath(...))read the node-set the way XPath 1.0 defines it — non-empty is true — instead of flattening it to the first node's text. Before,<discount>0</discount>, an empty<vip/>and<flag>false</flag>all read as "no match", while two<discount>0</discount>elements read as a match: a multi-node result was never flattened, so the old reading disagreed with itself. The other two questions keep their own answers —XPath<bool>("/order/flag")converts the node's text and is thereforefalse, and an expression that already yields a scalar (string(...),count(...) > 0, or an explicitXPathResult) is read by the one DSL truthiness rule. This is the object form only; thexpath('...')function of the expression language remains a value read, soFilter("xpath('/order/discount')")still answers by content — writexpath('boolean(/order/discount)')there. Breaking, and outside thedocs/V4/09-BREAKING.mdbundle listed above.
Added
Per-endpoint admission limits for the Kestrel family (HTTP, SOAP, AS2, gRPC) — load shedding, not backpressure. Kestrel executes as many handlers as requests arrive; a route used to have no ceiling at all. Four new options on those consumers —
maxConcurrentRequests(0 = unlimited, the previous behaviour),requestQueueLimit(FIFO wait slots),rejectStatusCode(default 429) andretryAfterSeconds(default 1, 0 = no header) — cap concurrent pipeline executions per registration and answer the overflow with429 Too Many Requests+Retry-AfterBEFORE any pipeline work. Strictly per-route: a saturated endpoint cannot eat a neighbor's budget on a shared listener. Implemented once, in the shared host, on the runtime's ownConcurrencyLimiter. gRPC counts unary calls only — a long-lived stream would hold a permit forever, and shed health probes would flap orchestrators (both documented). SignalR gets its own pair:maxConnections(an over-limit connection is aborted at OnConnected, before the Connected lifecycle event) andmaxParallelInvocationsPerClient(SignalR's native per-client parallelism).IEndpointStatistics.Rejected/RecordRejected()— requests shed by an admission limit before a pipeline ran. Counted in neitherMessagesInnorErrors, so dashboards can tell "shedding load" from "failing".Host-wide Kestrel backstop:
HttpHostingOptions.Limits.MaxConcurrentConnections/MaxConcurrentUpgradedConnections— Kestrel's own ceilings, previously never set (unlimited). The fuse for the whole port; the per-route limit is the thermostat.concurrentConsumers=autoon the broker family (RabbitMQ, AMQP 1.0, IBM MQ, SQS, MQTT, seda/vm — andmaxConcurrentCalls=autoon Azure Service Bus):auto= max(CPU count, 2), the NServiceBus formula. The default stays 1 — the industry norm (Camel JMS/SQS/Kafka, Spring, the Azure SDK all ship 1) because 1 preserves ordering and keeps handlers free of thread-safety obligations; parallelism is an explicit opt-in. BREAKING for code (not URIs): these options are now strings, because the URI binder silently turned any unconvertible value into the int default —concurrentConsumers=autowould have quietly meant 1, and a typo likeconcurrentConsumers=trashDID quietly mean 1. Both now fail at endpoint creation, naming the option and the valid values. IBM MQ topics still clamp to a single subscriber.IEndpointStatistics.BytesOut/RecordBytesOut— the missing half of the byte counter. The surface hadBytesInalone, documented as "total bytes processed" on the property and as "incoming bytes" on the method, and connectors resolved the contradiction each in their own way.SoapProducerrecorded the outgoing envelope asBytesIn;LlmProducerrecorded the outgoing prompt asBytesInand computed the incoming response only to throw it away — the code said so out loud:// The IEndpointStatistics surface only exposes RecordBytesIn … _ = bytesOut;. Both directions have their own counter now,BytesInmeans "bytes that entered this endpoint" andBytesOut"bytes that left it", and the docs on both say it.Corrected accordingly: the SOAP producer's request envelope moves to
BytesOut; the LLM producer's prompt moves toBytesOutand its response is recorded asBytesIninstead of being discarded — and is counted in UTF-8 bytes rather than UTF-16 chars, which is what a counter called bytes should hold ("привет"is 12, not 6). The WebSocket and TCP producers, which had the sizes at hand, record both directions too.Dashboards that read these numbers need a look.
BytesInon an LLM endpoint used to be the prompt and is now the answer; the prompt isBytesOut.redb.Tsakreads this surface. Adding a member toIEndpointStatisticsis also source-breaking for anything outside this repo that implements the interface —EndpointBaseis its only implementer here.ws://andsignalr://serve on the shared Kestrel, like HTTP/gRPC/SOAP/AS2 (F14 wave 1). Each of the two connectors used to raise its ownWebApplicationon its own port, so the hub and the REST API it pushes for could not sit on one port behind one proxy and one TLS termination — the typical production layout simply did not assemble. Nowhttp://:8080/api/**,signalr://:8080/hubandws://:8080/streamare one listener, and several hubs on it are routed to their own consumer by request path. Two new seams onSharedHttpServerManagermake it possible:EnableWebSockets(host, port, keepAlive, ssl…)andRegisterServerConfigurator(host, port, services, endpoints, ssl…)— services applied beforeBuild(), endpoints mapped before the catch-all. Both are opt-in per listener and fail loud if the listener is already running without them, so the four transports that already shared the host behave byte-for-byte as before (pinned by a new characterization suite,redb.Route.Tests.Hosting).Host-supplied authentication for the WebSocket and SignalR handshake (F14 wave 2).
AddRedbRouteSignalR(o => o.Authenticate = …)andAddRedbRouteWebSocket(o => o.Authenticate = …)take a delegate returning aClaimsPrincipalor null; null rejects the handshake with 401 before the connection is upgraded, and the principal'sNameIdentifierbecomes SignalR'sUserIdentifier(which is whatClients.User(...)and theredbSignalR.UserId/redbWs.UserIdheaders run on). A delegate rather thanAddJwtBearerbecause the process hosting a route context is a generic host, not an ASP.NET application. Note for implementers: the token arrives in theAuthorizationheader on negotiate and in theaccess_tokenquery parameter on the WebSocket upgrade (a browser cannot set headers there), so a host delegate has to read both.Backplane seam for scale-out (F14 wave 3).
AddRedbRouteSignalR(o => o.ConfigureHubServices(s => s.AddSignalR().AddStackExchangeRedis(…)))adds services to the hub's listener container without the connector taking a dependency on Redis or Azure. A hub keeps its connections and groups in the memory of one process, so with more than one replica a backplane is required: without it a broadcast reaches only the clients attached to the replica that sent it. Both halves of that sentence are now e2e tests against the live Redis container.A WebSocket route can push to its own clients (
mode=Server,Ws.Broadcast). The consumer kept a dictionary of connections and publishedActiveConnections, but had no send path into it: a frame could only be a reply to an incoming one. Broadcast, or one client by theredbWs.TargetConnectionheader — the id the incoming exchange already carried. This is half the point of a WebSocket server (quotes, notifications, progress), and SignalR had it while WebSocket did not.reconnectTimeoutfor the WebSocket producer. Withreconnect=trueand the defaultmaxReconnectAttempts=0, a send against a server that stays down never returned: the exchange did not fail, dead-letter never fired, the route just hung. The new time budget caps it; 0 keeps the previous behaviour, now documented rather than implied.redb storage as a first-class citizen of the XML markup (Route-XML step 1) — declarative verbs in
redb.Route.Coreand their R21 contributions. New string/Type DSL beside the lambda verbs (a lambda cannot live in markup):RedbGet(Type, idExpr, depth, storage, target),RedbGetJson(idExpr, …)— the whole object as raw JSON via the core's newLoadJsonAsync, so a pure-XML module without any assembly reads redb objects,RedbSave(Type?, byUnique, storage)— anIRedbObjectbody saved as is, a JSON body materialized through the type (byUnique→SaveByUniqueAsyncover the declared unique keys),RedbDelete(idExpr, storage); context-levelSyncRedbScheme(Type)(fail-fast at OnContextStarting) andAddRedbIdempotentRepository(name, ttl)(registry keyidempotent:{name}, the one<idempotentConsumer repository=…>already looks up). In markup:<redbGet id="${header.orderId}" [type=] [depth=] [storage=] [target=]/>,<redbSave [type=] [byUnique=]/>,<redbDelete id=/>,<beginRedbTransaction/>and the<redb>block of context.xml with<syncScheme type=/>+<idempotentRepository name=/>. Every verb resolves the service per exchange through the same scoped model as the lambda verbs and joins the ambient redb transaction.Context-level contribution seam (R21 grows a fourth position).
IXmlContextContribution(XmlElementKind.ContextLevel): a package element living inside<context>beside<components>/<bean>/<onInit>— applied to the context after beans, before<onInit>parsing; in route position it is a loud error; the generated XSD lists contributed blocks among the context children, and an unknown context section names them in its error.RedbQuery/<redbQuery>— server-side props queries from a condition string (Route-XML step 2). Thewherestring rides the engine's ONE expression AST (no new parser, §9) through a visitor into redb LINQ: props paths incl. nested (Customer.Name), comparisons,AND/OR/NOT,contains/startsWith/endsWithon string props; every subtree WITHOUT a props reference — headers, body, functions, date arithmetic — folds per message through the AST's own evaluation and enters the query as a constant (soCreatedAt < dateadd(now(), -1, 'day')and per-message header gates just work). What is neither translatable nor foldable refuses LOUDLY at route build — arithmetic over a props property, an unknown identifier (named with the property candidates) — never by silently filtering on the client; in markup the refusal is a positioned document error.orderBy/descending/take/skipride along (RedbQuerywithout any of them refuses: an unbounded scan must be asked for explicitly — saytake=);filter="#spec"is the full-LINQ escape hatch (IRedbQuerySpec<TProps>from the context registry, combinable withwhere). The resultList<RedbObject<TProps>>goes to the target (body/header/property). The condition may also live as the ELEMENT'S TEXT instead of thewhereattribute — a CDATA block frees it from<escaping (<redbQuery …><![CDATA[ CreatedAt < dateadd(now(), -1, 'day') ]]></redbQuery>); exactly one of the two forms. An ordering comparison on a string props property refuses with a hint (Expression trees have no<for strings) instead of a raw reflection error. TheorderByselector is typed with the member's own key type — the provider's ordering parser reads a plain property access, a boxing Convert it refuses (caught by the live-worker E2E, where the whole cycle ran green: a pure-XML module with zero assemblies syncing a scheme, saving a JSON body and querying it back server-side on SQLite).pack/checksee the package contributions.RoutePackage.Check/Buildtake the R21 extensions and the tool discovers them from--bin(the Default-ALC fallback keeps the interface identity), so a package using<redbQuery>/<cache>/<rest>passes the XSD gate exactly the way the worker's discovery will parse it — before this the pack gate knew only the core elements and refused any package element.The generator learned package namespaces.
IXmlElementContribution.GeneratedUsings— a printer whose verbs live outside the core namespaces declares itsusinglines and the generated file shell carries them (<redbGet>printsRedbGet(…)underusing redb.Route.RedbCore.Extensions;).The system prompt can be cached across turns (
redb.Route.Llm).?cacheSystemPrompt=trueon the model address — or.CacheSystemPrompt()on the fluent builder — asks the provider to mark the system prompt as cacheable. On Anthropic that means emittingsystemas a one-element block array carryingcache_control: {type: "ephemeral"}; without the flag it stays a bare string, so no caller sees a changed wire shape it did not ask for. The flag ridesAgentRequestthrough every iteration of the tool loop, which is where the saving actually accrues: a read costs a fraction of normal input, a write a premium over it, so it pays off from the second call.LlmUsagegrew the two numbers that say whether it works —CacheCreationInputTokensandCacheReadInputTokens, parsed on both the streaming (message_start) and non-streaming paths, defaulting to zero so existing construction sites compile untouched. The doc comment states the thing that is easy to get wrong:InputTokensis the uncached remainder, not the whole prompt, and a long agentic run reporting four thousand input tokens has not shrunk.Two ways it silently does nothing, both documented on the option: caching matches on the rendered prefix, so a system prompt carrying a timestamp or a per-user name is written and never read back; and there is a model-dependent length floor below which nothing is cached at all, with no error.
CacheReadInputTokensstaying at zero across repeated calls is the signal for the first,CacheCreationInputTokensstaying at zero for the second.Those two counters now survive the agent loop and reach the exchange (
llm.tokens.cache.write/llm.tokens.cache.read). They did not:AgentEnginesummed them per iteration and then rebuilt its final usage asnew LlmUsage(in, out), dropping both, so nothing downstream could observe the cache at all — "caching works" was unfalsifiable by construction. The totals are carried besideAgentUsagerather than inside it: that struct is the budget currency, and cache reads are not billable input, so folding them in would make every budget quietly wrong. Producer, consumer and the route-definition extension all write the pair, zeros included — a missing header is indistinguishable from an older engine, while a zero is an answer.Verified against the live API (
claude-haiku-4-5, two calls with a byte-identical system prompt):cacheRead = 16800on both,InputTokens16 then 15 — full price is paid for the user text alone. The live test is[EnvFact("REDB_LLM_ANTHROPIC_KEY")], so an ordinary run stays free and green.Fixed opening messages before the loaded history (
redb.Route.Llm,AgentRequest.Preamble, headerllm.preamble). A byte-stable preamble — a frozen opening exchange, a few-shot block — opens the transcript on every iteration, is never persisted to the conversation store and survives a branch rebuild.LlmMessage.CacheBreakpointasks the provider for a cache breakpoint after a message (Anthropic:cache_controlon its last content block), so the cached prefix reaches past the system prompt into the preamble instead of paying for it in full on every turn.HTTP/2 keep-alive pings on the Anthropic and Telegram transports (
redb.Route.Llm,redb.Route.Telegram). A non-streaming completion and a getUpdates long poll are tens of seconds of silence on the wire; VPN tunnels, NAT and proxies drop a silent TLS connection at ~50 s, which surfaced as "The response ended prematurely" — every long answer lost, every idle poll logged as a transient polling error. Both default clients now ask for HTTP/2 (HTTP/1.1 remains the fallback) and send a PING frame every 15 s while a request is in flight (SocketsHttpHandler.KeepAlivePingPolicy = WithActiveRequests). Measured through a sing-tun/xray tunnel: 45 s of silence survived, 55 s was cut; with pings four consecutive 50-second polls completed, without them HTTP/1.1 lost every one.Empty messages are no longer sent to Anthropic (
redb.Route.Llm,AnthropicProvider). A message with no content used to become{"type":"text","text":""}, which the API rejects with 400 "text content blocks must be non-empty" — and because the engine persists every assistant reply, ONE empty reply (the model spent its wholemax_tokenson thinking) broke every later call of that conversation until the history was edited by hand. Empty and whitespace-only messages are now skipped; the API merges consecutive same-role turns.LlmConnectionFactory.Effort(redb.Route.Llm). The effort ladder of models that think by default (Claude 4.6+ / Sonnet 5 / Opus 5), sent asoutput_config.effortonly when set; thinking tokens count againstmax_tokens, somedium/lowkeeps the answer inside the budget. Per factory on purpose: models without the ladder (Haiku 4.5) reject the field.Telegram file download and speech-to-text (
redb.Route.Telegram?mode=download,redb.Route.Llmstt://). The Telegram producer gained a download mode (GetFile→ size cap → bytes in the body,telegram.file.*headers); a newstt://scheme withITranscriptionProvider(OpenAiTranscriptionProvider,/v1/audio/transcriptions, local llama.cpp-style stands included) turns a voice note into text underllm.transcription.*headers.redb.Route.XPath2— a new package: XPath 2.0 expressions.using static redb.Route.XPath2.XPath2Dsl;thenXPath2("...")wherever a route takes an expression. It brings what XPath 1.0 has no answer for at all: regular expressions (matches,replace,tokenize), sequences (distinct-values,reverse,string-join,empty),avg/max,if…then…else,for $o in … return …,some/every … satisfies,xs:datecomparisons andinstance of. Source, bound parameters and trimming work as they do for XPath 1.0. It is XPath 2.0, not XQuery:let,whereandorder byare XQuery clauses and the engine rejects them when the route is built, so there is no sorting and no grouping — filtering goes in a predicate instead. There is noxpath2()function in the${…}language, which lives in the core assembly and does not know this package exists. Dependency:XPath2(MS-PL), which has none of its own, so the graph is two assemblies;XPath2.Extensionsis deliberately not referenced because it would pull Newtonsoft.Json back in. One expression object is safe to evaluate concurrently: the engine's compiled form mutates shared slots when the expression binds range variables (for/some/every) and itsClone()shares those slots, so evaluations run on a per-thread compiled form — found in review by a 400k-run parallel probe, pinned by a test.IPredicateExpression, and what it replaced. An expression that defines its own reading in a condition implements this interface rather thanIPredicate. The first cut usedIPredicate, and that was wrong for a reason worth recording: condition verbs are overloaded onIExpressionandIPredicateboth, so an expression implementing the latter madeFilter(XPath("/a/b"))an ambiguous call — the language would have gained the semantics and lost the spelling they were added for. The tests missed it because they passed expressions through anIExpression-typed parameter; there is now a compile-time guard that writes the call out directly.Six EIPs take an expression, not only a delegate.
Aggregate,RecipientList,DynamicRouter,IdempotentConsumer,EnrichandPollEnrichwere typed onFunc<IExchange, …>alone, so a correlation key out of an XML body meant hand-writinge => XPath("/order/id").Evaluate<string>(e)at every call site. They now also accept anIExpression:Aggregate(XPath("/order/customerId"), strategy, completion),IdempotentConsumer(repo, jpath("$.messageId")),RecipientList(XPath("/order/to/uri")),DynamicRouter(XPath("/order/next")),Enrich(Header("service"), merge),PollEnrich(Header("source"), timeout). The existing delegate overloads are untouched. Wrapping was never the problem; wrapping once per call site was, because each hand-rolled wrapper decided on its own what an expression that matched nothing means — that decision now lives in one place: a missing correlation key, idempotency key or endpoint URI fails the exchange naming the EIP, while Dynamic Router reads "no value" as "no more hops" because that is what it means there.RecipientListaccepts either a sequence of URIs or one delimited string (uriDelimiter, default,), mirroringRoutingSlip(IExpression, …).XPath takes bound parameters instead of concatenated ones.
XPath("/order[@id=$id]").WithParameters(("id", Header("orderId")))binds the header to the$idvariable, so the value is compared and never parsed: a header holdingB-2' or '1'='1selects an order with that literal id and matches nothing, rather than changing what the query asks. Building the path by concatenation — which is what Camel'sallowSimpledoes — is why XPath injection has an OWASP entry and a CodeQL query of its own; binding values is the settled answer everywhere else (JAXP'sXPathVariableResolver, XQuery'sdeclare variable $x external, .NET'sXsltArgumentList, SQL prepared statements). It also keeps the path one constant string whatever the message says, which is what lets a compiled expression be reused. Numbers bind as XPath numbers, invariantly, so a route does not change meaning with the server's locale. An expression carrying bindings or a source refusesToTemplateString()— the${xpath(path)}form cannot carry either, and serialising without them would mean something else; only unprefixed variables resolve, so$x:idfails as undefined rather than quietly borrowing the binding namedid. XPath only:jpathhas no equivalent, and not because of the library — RFC 9535 has no variables, so there is nothing for JsonPath.Net to expose.The XSLT engine is now actually replaceable.
IXsltEnginehas been an interface since 3.5.0 and its documentation said a Saxon-backed engine could be plugged in — it could not: the.Xslt(...)and.XsltContent(...)verbs and thexslt:component each named the built-inXslCompiledTransformEnginedirectly, so there was nothing behind the interface to substitute. Substitution goes through the newIXsltEngineFactory(the factory, not the engine, because a stylesheet has to be compiled and compiling is what a different processor does differently), registered withcontext.UseXsltEngine(...)orservices.AddXsltEngine(...). Without a registration nothing changes: the default is the same BCL engine, XSLT 1.0, no dependencies. We still cannot ship XSLT 2.0/3.0 — every .NET processor that implements it is commercial — but a route that has one can now use it under its own licence.Namespaces are reachable from the DSL.
XPath("/s:Envelope/s:Body").WithNamespaces(("s", "http://schemas.xmlsoap.org/soap/envelope/")). The expression always accepted anIXmlNamespaceResolver, but only through its constructor, so a route written in the DSL could not query a namespace-qualified document at all — an unbound prefix does not match nothing, it refuses to run. The prefixes are the expression's own and need not match the document's. Camel keeps namespace sets in a registry (namespacesRef) because its XML DSL cannot write a map inline; in C# a shared set is an ordinary variable, so there is no registry here.XPath(...).Trimmed()trims whitespace off extracted text. Off by default, which is where this library has always been and differs from Camel'strim=true. For a single value XPath answers this itself withnormalize-space(...); a node-set of several padded values cannot be normalised from inside XPath 1.0, which is what the option is for.CompiledXPathExpressionis anIExpression. It computes its XPath per message — a selector from a header, a per-tenant rule — and was public and tested but implemented nothing, so there was no position in the DSL that would take it. It now goes wherever an expression goes, including the six EIPs above.xpathandjpathcan read something other than the body. Both languages take a source:XPath("/invoice/id").From(Header("original-request")),JPath("$.id").From(Property("doc")), and in the expression language a second argument —${xpath('/order/id', header.payload)},jpath('$.id', property.doc). One argument still means the body, and the body is left alone: before this, querying XML that arrived in a header meant moving it into the body first, which costs the route its body for every step after. The source is an ordinary expression rather than Camel'sheader:payloadstring prefix, so a typo inheader.payloaddis a parse the route can check instead of a string nobody reads. Camel writesxpath(input,exp)with the source first, which makes the first argument mean different things at one and two arguments; we keep the path first at both arities, so a route ported from Camel needs its argument order swapped. Inside the language a source that produced nothing reads as null, the same answer the one-argument form gives a null body — the language is lenient about missing data throughout, and a condition over an absent optional header is "no match", not an error. The strict contract lives on the object form:From(...)naming a source that produced nothing fails loudly.Aggregate,RecipientList,DynamicRouter,IdempotentConsumer,EnrichandPollEnrichwere typed onFunc<IExchange, …>alone, so a correlation key out of an XML body meant hand-writinge => XPath("/order/id").Evaluate<string>(e)at every call site. They now also accept anIExpression:Aggregate(XPath("/order/customerId"), strategy, completion),IdempotentConsumer(repo, jpath("$.messageId")),RecipientList(XPath("/order/to/uri")),DynamicRouter(XPath("/order/next")),Enrich(Header("service"), merge),PollEnrich(Header("source"), timeout). The existing delegate overloads are untouched. Wrapping was never the problem; wrapping once per call site was, because each hand-rolled wrapper decided on its own what an expression that matched nothing means — that decision now lives in one place: a missing correlation key, idempotency key or endpoint URI fails the exchange naming the EIP, while Dynamic Router reads "no value" as "no more hops" because that is what it means there.RecipientListaccepts either a sequence of URIs or one delimited string (uriDelimiter, default,), mirroringRoutingSlip(IExpression, …).XPathResult— the XPath-level result type, which is a different question from the CLR type the caller converts to (Apache Camel draws the same line asresultQNameagainstresultType):XPath("/order/total", XPathResult.Number), plusString,Boolean,Nodeand the defaultNodeSet.string(),number()andboolean()are XPath's own coercions, so the engine performs them rather than the conversion layer guessing;Nodenarrows a node-set to its first match in document order. Available onXPathExpression, onTypedXPathExpression<T>— the two compose,XPath<int>("/order/qty", XPathResult.Number)— and through theXPath(path, result)/xpath(path, result)DSL helpers. An expression carrying a non-default result type refusesToTemplateString()instead of serializing to an${xpath(path)}that would mean something else, because the language function takes a path and nothing else.FCM multicast and topic management (F11 D5–D6, red-before against fcm-echo).
operation=Multicastsends one message to many device tokens (IEnumerable<string>body or a comma-separatedredbFcm.Tokensheader) viaSendEachForMulticastAsync;SubscribeToTopic/UnsubscribeFromTopicmanage topic membership for a token list. All three finally populate theSuccessCount/FailureCountheaders that had been declared "(future)" since the first release. Neither exists in Apache Camel at all.Firestore
databaseIdfor multi-database projects (F11 D7, red-before). The connector was hardwired to(default); the option/DSL.DatabaseId(id)reaches the client and the cache key, so two endpoints on different databases of one project no longer share a client.Firebase Storage
StreamBodydownload is true streaming now (F11 D8, red-before). The body used to be a fully bufferedMemoryStreamwearing a stream costume; the download is pumped through aPipein the background and the exchange gets the reader side — memory stays flat regardless of object size, and a background failure surfaces on the next read.Firebase Storage: Copy, signed URLs and bucket operations (F11 D1–D3, all red-before against fake-gcs).
Copy— server-side copy withdestinationObjectName/destinationBucket(option or header);CreateDownloadLink— signed download URL with a TTL (signedUrlExpiration, default 1h; requires a service-account JSON — plain ADC cannot sign, and the error says so);CreateBucket/DeleteBucket/ListBucketsplusAutoCreateBucketon the consumer — parity with the S3 sibling and camel-google-storage.Polling consumers can share a NAMED idempotent repository (F11 D4, red-before). Firebase Storage and S3 consumers take
idempotentRepository=<name>— the sameIIdempotentRepositorycontract the route-level IdempotentConsumer EIP uses, registered viacontext.AddIdempotentRepository(name, repo). Two-phase claim (Add → process → Confirm/Remove): a failed object is released and retried, a concurrent consumer cannot take the same object twice, and with the persistentRedbIdempotentRepositorydeduplication survives process restarts and scale-out. The per-consumer in-memory dictionary stays the default.Firestore
Wheresyntax finished (F11 G6, owner decision, red-before). Single-quoted values are strict string literals (status=='007'no longer coerces to the number 7);array-containsis recognized only as a spaced token, so a field whose NAME contains the substring no longer tears the condition apart;${...}templates inWhereresolve per exchange on the producer Query operation (through the shared expression engine — no new string dialect), and a consumer with${...}inWherefails loud at startup instead of producing a nonsense subscription.Firebase connector, wave G (F11):
FirebaseCredentialProvideris public withDefaultProjectId/DefaultCredentialPath— a named instance goes into the context registry andconnectionFactory=…finally has a usable implementation; Firestore consumer gainedMaxConcurrency(default 16 — an initial snapshot no longer floods the pipeline with the whole collection in parallel); BatchWrite gainedDocumentIdField(document id from an item field, stripped from the data) next to the auto-ID default. DSL:.MaxConcurrency(n),.InitialDelay(ms),.DocumentIdField(name).Core registration primitives for connector packages (F11 wave B):
services.AddRouteComponent<TComponent>()— singleton + startup configurator in one call — andservices.AddRouteContextConfigurator((sp, context) => ...)for connectors whose registration wires more than a bare component (shared Kestrel manager, named registry entries, several components). All 28 connector packages now build on these two.Firebase Storage consumer:
MoveFailedquarantine prefix (F11 wave A). Objects whose processing failed are moved (copy + delete) into the given prefix instead of being retried on every poll — the CamelmoveFailedpocket. DSL:.MoveFailed("failed/").redb.Route.Xml(new package): declarative XML routes — first vertical slice (Route-XML F2). A.route.xmldocument loads into the existing fluent DSL through a contribution registry (decision R21): every element is oneIXmlElementContributionfacading one DSL verb, the core registers its own elements through the same contract packages will use (XmlRouteLoaderOptions.Extensions), and a duplicate element name is a hard error, never a silent override. The whole document parses eagerly at load: every problem — an unknown element (with a "did you mean" hint), a malformed expression,valueandexprtogether, a${...}in a consumer URI, an endpoint scheme not registered in the context — is collected in ONE pass with file/line/column positions and thrown as oneXmlRouteException; a document with errors registers nothing. This slice carries the container (<bean>with<property>binding through the shared option converter,<route>with its attributes incl. the newenabled=— false skips registration entirely), twenty leaf steps,<filter>/<split>scopes and<choice>/<when>/<otherwise>branching; the genericid/descriptionattributes flow into step identity (StepId/StepDescriptionin message history). Entry points:AddXmlRoutesFromContent(xml)andAddXmlRoutes(files)onRouteContext. The remaining element catalog, container-level handlers, the structured endpoint form andcontext.xmlfollow in further increments of the phase.redb.Route.Xml: the rest of the F0 §4 element catalog (Route-XML F2, second increment). ~35 more elements through the same registry. Leaves:<sort>,<sample>,<streamCaching>,<validateJsonSchema>/<validateXsd>(file XOR inline content; files load through the F1.4 resource resolver),<xslt>(file passes through to the runtime resolver),<marshal>/<unmarshal>(registeredformat=ortype=with per-step option attributes bound to a fresh serializer instance; options withformat=are refused — registry serializers are shared;<unmarshal>with neither dispatches by the messageContentType),<controlBus>,<enrich>/<pollEnrich>(named strategies viaAggregationStrategies.ByNameor#registry),<recipientList>,<dynamicRouter>,<routingSlip>,<claimCheck>, the transaction verbs,<exceptionHandled>,<routePolicy ref=>,<setHeaders>with<header>children. Scopes:<multicast>,<aggregate>(strategy attribute is REQUIRED — owner decision: no silent default),<tryCatch>+<try>/<catch>/<finally>,<loop>(count/expr/while — exactly one),<throttle>(plain and keyed, expression maxPerPeriod,rejectOnOverflow),<debounce>,<circuitBreaker>+<fallback>,<idempotentConsumer>,<resequence>,<transaction>(named policies default/requiresNew/suppress/mandatory),<traced>,<metered>,<replayable>,<threads>,<ofType>(a typed converting section — children belong to it, siblings continue on the route), route-level<onException>,<intercept>/<interceptFrom>/<interceptSendToEndpoint>/<onCompletion>(childless<when expr=>inside them is the condition),<scatterGather>+<recipient>,<loadBalance>+<endpoint>(five strategies, weighted validates a weight on every endpoint),<normalize>+<when>/<whenContentType>/<otherwise>withtransform=expressions, rich<log>with<message>/<header>/<property>children (text and children together is a schema error), and the<split>tokenizers<tokenizeLines>/<tokenizeXml>/<tokenizeJsonArray>.<saga>stays deferred: its DSL is lambda-only and has no XML shape yet.redb.Route.Xml: container-level handlers and glob loading (Route-XML F2, third increment).<onException>,<intercept>/<interceptFrom>/<interceptSendToEndpoint>and<onCompletion>are now valid directly under<routes>— declared on the builder, they apply to every route of the file, and they are configured by the SAME helpers the route-level elements use (no second parsing copy). NewAddXmlRoutes(globPattern)overload:"routes/*.route.xml"loads every match in deterministic ordinal order, a pattern matching nothing is an error naming the searched places, and a wildcard-free argument loads that one file; single-file loading now resolves relative paths through the route resource resolver (AppContext base before the working directory), sodotnet runfrom another directory stops changing which files load. The R21 extension channel is exercised by tests from outside the core: a registered contribution parses like a core element, an unregistered one is rejected, and a name clashing with a core element fails registration hard.redb.Route.Xml: the structured endpoint form — F0 §7.2 (Route-XML F2, fourth increment). An address-carrying step (<from>,<to>,<toD>,<wireTap>,<enrich>,<pollEnrich>) now takes eitheruri=or exactly one child endpoint element:<to><kafka path="orders" key="${header.tripId}"/></to>, or the SQL shape with the query as CDATA content,<param name=… value=…/>family entries (→param.login=…options) and long text options as children (<onSuccess><![CDATA[…]]></onSuccess>). ONE generic procedure normalizes any scheme into the same URI string a hand-written address would be — before the endpoint exists, so the whole engine pipeline (endpoint cache,NormalizedKey, statistics, mock masks, secret redaction) sees a single canon and gains zero new code; there is no switch per connector and no per-connector XML code at all (the owner's anti-copypaste rule by construction; per-connectorpathsynonyms like kafka→topicarrive with the F4 catalog). Option values travel raw like the fluent builders emit them, except%→%25and&→%26— the two charactersEndpointUriParser's query parse cannot survive — so ordinary values stay byte-identical to the URI form and SQL with&/LIKE '%…%'round-trips exactly.uri=together with a child, two children,path=plus text content,?in a path and a malformed option child are all positioned schema errors; scheme checking and the${…}-in-consumer refusal work on the assembled string with the child element's position.redb.Route.Xml:context.xml— the context-level document (Route-XML F2, fifth increment).AddXmlContextFromContent/AddXmlContext(file)load a<context xmlns="urn:redb:route:1.0">with three sections:<components>(an explicit<component type=…/>list registeringIComponents for the schemes route files use), context-wide<bean>declarations — now with nested anonymous beans in<constructorArg>(an options object built inline, properties bound through the shared converter, recursively), and<onInit>— a pipeline of ordinary format steps (DDL through a connector producer, seeding throughbean:) parsed by the same element registry and run ONCE insideOnContextStarting, the engine's only fail-fast bootstrap hook: a failed step keeps the context from accepting traffic and the error names the source file; a failedStart()retries the init on the nextStart(). Also:enabled=on<route>is now{{…}}-aware — resolved through the context's own placeholder chain (IConfiguration, then context properties,{{key:default}}supported), so a route can be switched per stand from configuration; an unresolved key without a default and a non-bool result are positioned schema errors. One core seam for this:RouteContext.ResolvePlaceholderswent private→internal so the loader reuses the existing lookup chain instead of copying it.redb.Route.Xml: the XSD is generated from the element registry (Route-XML F4, first batch). Every contribution now carries a publicElementSpec(attributes with types and enum values, nested branch/config children, content flags) — the R21 contract grew a default-implementedSpecproperty, so existing contributions keep compiling and a package that declares its shape gets schema validation and editor autocompletion for free.XmlRouteSchema.Generate(registry)emits the XSD for the INSTALLATION's registry (one namespace,urn:redb:route:1.0, per R21): the step vocabulary, scopes with their children, enum hints (xs:enumeration— editors offer the values), required attributes,id/descriptioneverywhere, foreign-namespace attributes tolerated (anyAttribute ##other lax, R9), foreign elements rejected; the §7.2 endpoint children are lax open content until the component catalog supplies the scheme elements. Tests pin the one-list guarantee both ways (every core contribution has a spec, every spec belongs to a contribution), validate all five shipped examples against the generated schema, and show a package contribution appearing in and disappearing from the schema with its registration. The §3.2 version policy is enforced on load: a newer minor says "update the redb.Route.Xml package", a different major is named a different format.redb.Route.Xml: route packaging and the project scaffolder (Route-XML F5, the Tsak-independent half).redb-route-xml new <Name>scaffolds a route project the way sketch 11 drew it: an ordinary.csproj(VS Code, Visual Studio and Rider all open it),context.xml,routes/,resources/, the generated XSD wired into.vscode/settings.json, and OUR layered-configuration convention — the L4 in-package config holds module identity ONLY (ContextName+AutoStart), settings and secrets arrive through the merged context configuration at deploy time (the identitycontext.jsonconveyor as the model; the everything-in-the-package legacy style with literal passwords is exactly what the checks warn about).redb-route-xml packbuilds the F5.1 package layout (manifest.jsonwithArtifacts/SchemaVersion/Resources/Context, the L4 config, artifacts, resources) into a.tpkgzip, andcheckis the same gate for CI. The F5.4 checks run first and a hard error refuses the build: well-formedness and schema validation with positions,]]>inside CDATA,file=resources missing fromresources/, undeclared#namereferences (named as either module-code-registered or dangling), a literal-secret heuristic (warning), and every{{key}}without a default recorded in the manifest'sRequiredConfigKeys— the required-keys manifest the module will fail fast against at init. The Tsak side (manifest fields inModuleManifest,XmlRouteModule, package resolvers) stays untouched by owner instruction — the layout is exactly what it will consume.redb.Route.Xml: the code generator — XML printed as the fluent C# it parses into (Route-XML F6).Printis the third face of the R21 contribution besideApplyandSpec(default: a package element without a printer is an explicit generator error, never a silent skip).XmlCodeGenerator.Generate(document, className, ns, style)emits aRouteBuilderclass: readable style for migration (descriptions become comments), machine style adds#linedirectives so stack traces and breakpoints point at the XML (the Razor trick). Generated code is STATEMENTS against receiver variables, never one long chain — a chain dies at the first scope closer (End*returns the facade), which is exactly why the F3 equivalence twins were written with variables; the model also expresses theofTypetyped section naturally (a variable that never closes). Beans print through the new runtime helperXmlBeans.Create— the same resolver-and-converter semantics the<bean>section has, nested anonymous beans recurse. The honesty check is mechanical and committed: all five examples generate C# that lives in the test project (a golden — regenerate deliberately), COMPILES there, and produces definition trees byte-identical to loading the XML.MermaidRenderer(core,Diagnostics) is the third receiver of the same walk: flowchart diagrams from the definition tree — both spellings of a route draw identically — with committed.mmdgoldens for every example.redb.Route.Xml: the component catalog + §7.2 metadata on the component (Route-XML F4, second batch).ComponentBasegained the two one-liners F0 §7.2 sanctions:StructuredPathSynonym(kafka →topic— read by the loader, the catalog and the schema, never from a hand-kept list) andPathIsText(sql: the structured form takes the path from CDATA content and refuses a path attribute).ComponentCatalog.Build(components)describes every connector by reflection over what it already has — scheme, synonyms, itsOptionsclass (found by naming, or as the single candidate in a single-component assembly), options with types, defaults, enum values and[Sensitive]marks — andToJsonemits the file the editor's property panel reads. A catalog-awareXmlRouteSchema.Generate(registry, catalog)turns the §7.2 endpoint children from lax open content into a STRICT set of scheme elements with typed, enum-hinted options. The anti-copy-paste criterion is a test: a connector with Options, a Scheme and one synonym line appears in the structured form, the catalog and the strict schema with zero XML-specific code. The all-connectors catalog TOOL is deliberately deferred: other agents are working in connector packages right now, and building their projects from here is against the multi-agent discipline — the mechanism is proven, the tool run is a quiet-window follow-up.RouteDescriber(new,redb.Route.Diagnostics) + the F0.3 examples become executable contract (Route-XML F3). The describer renders a definition tree as stable, diffable, human-readable text — one generic reflection walk (node kind, significant parameters, conditions through the V4IConditionSource.SourceTemplateseam; never delegates, instances or hash codes) — shared by the XML-vs-C# equivalence tests, the future F6 generator round-trip and Mermaid. TestKit needs ZERO adaptation over XML routes: the canonical flow (AddXmlRoutesFromContent→AdviceAllRoutes(MockEndpoints)→Mock→SendBody→AssertIsSatisfiedAsync),WeaveByIdby the XMLid=attribute, and the no-password-leak guarantee are all covered by tests. Every shipped example underdocs/Route-XML/examples/now LOADS through the real loader in tests (stub schemes, mapped bean types, the examples directory as the resource root) and has a committed golden description; a golden is never updated to go green. The four translated examples (scope-diag, eip, deep-dsl-showcase, main-pipeline) each have an equivalence test: the tree loaded from XML and the tree built by the post-V4 fluent C# twin are BYTE-IDENTICAL — the R1 promise (one engine, two spellings) made executable on real material. The package README gained a "Testing an XML route" section. Loading the examples flushed out three real defects, each fixed red-before:- the named-repository
IdempotentConsumeroverload existed on the base but not on theIRouteDefinitionfacade, and the string-form extension patched over it with a cast to the concreteRouteDefinition— inside any scope (<throttle>, …) it refused to build. The facade now declares the overload and the cast is gone; <bean>property and<constructorArg>values did not resolve{{key}}/{{key:default}}through the context's placeholder chain before type conversion —{{ldap.port:636}}failed to bind to anint, defeating the whole point of the section (configuration-fed beans);id=/description=on branch children (<when>,<otherwise>,<catch>,<finally>,<fallback>) were silently dropped — R12 promises them on every element; they now land on the branch definition itself.
- the named-repository
redb.Route.Xml: namespace discipline — no local-name smuggling. The parser matched child elements by local name only, so<alien:setHeader>from a foreign namespace silently parsed as the core element — a hole that would undermine the whole XSD story of the F4 catalog. Fixed red-before: every element of a document must live inurn:redb:route:1.0; a foreign or empty namespace is a positioned schema error. The extension-namespace strategy for package contributions is written up as a decision fork in the F4 document (§3.5).redb.Route.Xml: the DI surface (Route-XML F2, sixth increment). The same loading overloads onRedbRouteBuilder: insideAddRedbRoute(r => r.AddXmlRoutes("routes/*.route.xml"))(plus the file-list,AddXmlRoutesFromContentandAddXmlContextforms), applied at context-start through the standardIRouteContextConfiguratorhook — an ordinary ASP.NET host runs XML routes with one line, no Tsak required; a document with errors fails the host start with the aggregated positioned report.Camel-style parameter binding for
bean:(Route-XML F1.6, owner decision).bean:#svc?method=validate(${body}, ${header.id})— each argument is a route-language expression and the method's parameters carry ordinary types (bool Validate(Order order, string id)), so a bean stops being coupled toIExchange. The overload is picked by argument count (several same-count overloads are refused with the list — binding does not resolve by type); an optional trailingCancellationTokenparameter is filled automatically; an empty list (method=run()) calls a parameterless method. Argument expressions compile at endpoint creation (a malformed one fails the build) and values convert through the shared option converter — a null into a value-type parameter fails naming the parameter. The(IExchange[, CancellationToken])signatures stay the first-priority form, untouched. Boundary: an argument must not contain a bare&(the URI query splits on it) — write the word formAND. Deliberately not in this cut:@Body/@Header-style parameter attributes and Camel's implicit body-to-first-parameter binding — tracked in the injections research.The
bean:component — user code as an ordinary endpoint (Route-XML F1.2, decision R8). Two URI forms:bean:#name?method=Xtakes the object from the context registry;bean:Namespace.Type, AssemblyName?method=X&prop=valuecreates the instance throughActivatorUtilities(constructor DI works), binds the remaining query parameters onto its public settable properties through the same converter the endpoint options use (extracted as the sharedOptionValueConverterrather than duplicated), and registers[Sensitive]-marked property names with the URI redaction set. A suitable method takes(IExchange)or(IExchange, CancellationToken);method=is optional with exactly one candidate and the ambiguity error lists them; a non-null result (Tor awaitedTask<T>) becomes the body,void/Taskleave it. The method is compiled into a delegate at endpoint creation — reflection never runs per message. Loud, specific errors: a version-pinned type name is refused (a pinned version breaks the route on every assembly bump), "not found" and "found but not public" are distinct, an unknown property names the writable ones. The instance lives as long as the endpoint (cached by normalized URI: equal URIs share, different parameters get their own).IBeanTypeResolveris a context service so a host that loads user code into its ownAssemblyLoadContext(a Tsak package) can search its assemblies first; the default resolver searches every loaded assembly, collectible contexts included. Camel-parity note: Camel's parameter binding (method=handle(${body}, ${header.x})) is deliberately not in this first cut — the injection/parameter-binding research is tracked separately.String forms for the remaining lambda-only EIPs (Route-XML F1.3). Following the 4.0 rule — the string form lives on the verb, never under an
*Expressionname, and compiles at declaration so a malformed expression fails the build:RecipientList(string, delimiter, …)(the expression may yield a collection of URIs or one delimited string),DynamicRouter(string)(null/empty stops routing),Resequence(string, batchSize, timeout),Debounce(string, quietPeriod),IdempotentConsumer(string keyExpression, string repositoryName, …),UseSticky(string)on the load balancer,Normalize'sWhen(string condition, transform), the non-genericOfType(Type)(parity of construction withOfType<T>(), for callers that learn the type at load time), andRoutePolicy(string policyName)— resolved from the context registry when the route compiles, a missing registration failsStart()naming the route and the policy.Aggregatefrom a correlation string, completing by condition, size and inactivity timeout.Aggregate(string correlation, strategy, completionCondition?, completionSize?, completionTimeout?): any combination, whichever fires first completes the group, and at least one criterion is required — an aggregate that can never complete is a leak, not a default.completionSizerides a counter the strategy wrapper stamps on the accumulated exchange (an engine-internal__-prefixed property, invisible to templates);completionConditioncompiles through the same predicate path asFilter(string).completionTimeoutis new engine capability with Apache Camel semantics (the inactivity clock restarts on every arrival for the group):AggregatorProcessorgrew a periodic scan timer in the resequencer's established shape — fire-and-forget flush, an exception from the target lands on the exchange — and is disposed with the route.AggregateDefinition.CompletionTimeoutis public for the XML form.Three expression-language additions: the modulo operator
%,uuid([format])anddatediff(a, b, unit)(Route-XML F1.5, owner decision).%sits beside*and/in precedence, is whitespace-insensitive like every operator, coerces its operands like division does (invariantly; a zero divisor yields null, a whole result collapses to int) — soFilter("header.n % 2 == 0")partitions by key.uuid()is a fresh GUID as text (Dformat;uuid('N')compact) and is impure by design: every evaluation yields a new value, which the expression caches never memoize (they store delegates, not results).datediffis first minus second indateadd's unit vocabulary plusms; dates coerce exactly asdateaddcoerces them (string dates parse invariantly), an unknown unit yields null likedateadd. All three are implemented once and dispatched from both engine branches (compiled and interpreted); the characterization grid grew eleven rows — value, condition and template agree on every one — with zero drift across the 225 previously recorded forms. Adding%also taught the value dialect's operator detector about it (narrowly: only%, quoted literals masked), soExpr("header.a % 5")computes instead of reading null.Resource resolution for file-reading endpoints:
IRouteResourceResolver(Route-XML F1.4).validator:andxslt:used to probe their schema/stylesheet with a bareFile.Exists, i.e. relative to the process working directory — inside an unpacked package that directory is the worker's, not the package's. Both components now resolve the path through the context's registeredIRouteResourceResolver(a context service; hosts register an instance with their package root). The default chain probes the absolute path,ResourceRoot(when set),AppContext.BaseDirectory, and the working directory — deliberately last, so today's behaviour survives as the final fallback. A miss fails endpoint creation with every probed location named in the message instead of the bare original string.Step labels and honoured step ids in message history:
Description("...")DSL verb (Route-XML F1.1).Descriptionlabels the most recently added step (ProcessorDefinition.StepDescription, Apache Cameldescription(); before any step it describes the route itself), and message history now honours both identity verbs: a node's history id is itsStepIdwhen one was assigned viaId("...")(before: always the generated label+counter), and its label is theStepDescriptionwhen set (before: always the type-derived label). The counter is still consumed from the type-derived label for every node, so naming or describing one step never shifts the generated ids of its neighbours; a route with neither verb records byte-identical history. A duplicate step id within one route now fails route validation atStart()(case-insensitive, matchingWeaveById) instead of producing unattributable traces.format(value, pattern[, culture])in the expression language. The explicit locale escape hatch the invariant-${...}change points at:${format(header.price, 'N2', 'ru-RU')}is19,95,${format(header.price, 'N2')}is19.95whatever the server's culture. It was named in the migration note and the expressions guide before it existed — the compiler answeredNotImplementedException: Compilation of function 'format' is not yet implemented, so the breaking change had removed locale formatting instead of relocating it. Works for anythingIFormattable(numbers, dates,TimeSpan,Guid); a string that holds a number or a date is parsed invariantly first, other text comes back as itself,nullstaysnull. An unknown culture name is an error, not a silent fall back to invariant. Both branches of the engine (compiled and interpreted) share one implementation.String forms on the verbs:
Loop(string countExpression, copy, shareScope)(count from the message;LoopWhile(string)remains the condition form),Delay(string durationExpression)(milliseconds, aTimeSpan, orhh:mm:sstext),Throttle(string maxPerPeriodExpression, TimeSpan? period). A malformed expression fails at route build.redb.Route.Cache(new package): cache as an EIP (WSO2Cachemediator / Camel cache component analog) over the .NET caching abstractions —IMemoryCachein-process (created on demand) or anyIDistributedCache(Redis, SQL Server, …). Two forms sharing one store:.Cache("customer-${header.customerId}", TimeSpan.FromMinutes(5)) // scope: hit → skip inner steps .Enrich("http://crm/customers/${header.customerId}") .EndCache() .To("cache:customers?action=get&key=${header.customerId}") // component: get | put | remove | clear .Filter("header.cache.hit == false").Enrich("http://crm/...").To("cache:customers?action=put&key=${header.customerId}&ttl=5m").EndFilter()Scope options:
Region,Ttl,SlidingExpiration,CacheHeaders,Distributed()/InMemory(),KeyFromBody()(SHA-256 of the body). Headercache.hiton every pass. A POCO body goes through a distributed cache as JSON with its CLR type and comes back as that type. Durations in URIs:500ms,30s,5m,2h,1d,hh:mm:ss. Setup: nothing for in-process;context.UseCache(o => …)/services.AddRedbRouteCache(o => …)for options (MaxEntries,DefaultTtl,DefaultProvider) and thecache:component in DI.NodePipelineis now public so scope nodes can live in packages.redb.Route.JsonTransform(new package): declarative JSON-to-JSON transformation with JSONata (Cameljslt/jolt/jsonataanalog) on the native .NET engine Jsonata.Net.Native 3 — no JavaScript, no JVM.TransformJson("Transforms/order-to-shipment.jsonata")(file locator, compiled once, cached) orTransformJson(TextSource.Inline("{ 'id': orderId, 'to': { 'city': address.city } }")). Input: JSON text / bytes /Stream, a System.Text.Json tree, or a POCO;$headersand$propertiesare bound (the same names as in payload templates); output JSON text withContentType(Indentoption) or aJsonNode(JsonTransformOutput.Node); an undefined result is anullbody. A bad specification or a missing file failsStart()with the specification name. Options viaservices.AddJsonTransform(...)/context.UseJsonTransform(...). XML:<transformJson spec="…"/>.TextSourcein the core — one abstraction for "text from a file / embedded resource / inline" shared byredb.Route.Templatesandredb.Route.JsonTransform:TextSource.File(path),TextSource.Embedded(assembly, path),TextSource.Inline(text),TextSource.FromLocator("assembly:Name/path"). (TemplateSourceof the unreleased Templates package became this type.)REST DSL in
redb.Route.Http(Camelrest()parity). A declarative layer over the existing HTTP consumer and the shared Kestrel host: every verb becomes an ordinary routeFrom("http://host:port/base/path?methods=GET&inOut=true"), so path templates, method dispatch (405), CORS and TLS stay where they already are.this.Rest("/api/orders", o => { o.Port = 8080; o.BindingMode = RestBindingMode.Json; }) .Get("/{id}").Produces("application/json").OutType<Order>().To("direct:get-order") // header.id, header.query.page .Post().Consumes("application/json").Type<Order>().To("direct:create-order") // 415 on another Content-Type .Put("/{id}/status").To("direct:set-status") .Delete("/{id}").Route().Process(...); // inline stepsPath parameters arrive as plain headers (
header.id), query parameters asheader.query.*;Consumes/Producesset the media types (a mismatching request is answered with 415); JSON binding unmarshals a declaredType<T>()and marshals an object body back; no body and no status → 204; the status code stays the consumer'sredbHttp.ResponseCodeheader. An OpenAPI 3.0.3 document (paths, path parameters, request / response schemas with$refs,operationId= route id,summary=Description) is served at{basePath}/openapi.json(configurable / off). Several declarations share a port.RouteBuilder.Fromis now public so DSL layers can add routes.Interception:
Intercept(),InterceptFrom(uriMask),InterceptSendToEndpoint(uriMask)(Camel parity). Declared on a route or on theRouteBuilderfor every route it defines; each opens a scope of steps with an optional.When("..."). They are not route steps but compile-time decorators, soIntercept()reaches steps nested inChoice,Split,Multicast,TryCatchand every other scope;InterceptFromfires once on entry;InterceptSendToEndpointwrapsTo/ToDwhose target matches (dynamic targets are matched per message), publishes the target in headersredb.toEndpointandCamelToEndpoint, and.SkipSendToOriginalEndpoint()replaces the send — a dry run that never even resolves the original endpoint.Stop()inside an intercept stops the exchange; an intercept's own steps are never intercepted.InterceptSendToEndpoint("kafka://*").When("property.dryRun") .Log("dry run: would send to ${header.redb.toEndpoint}") .SkipSendToOriginalEndpoint();OnCompletion()(Camel parity): steps that run after the route finished with an exchange, on a copy, outside the route's transaction and error handlers, never changing the route's result..OnCompleteOnly()/.OnFailureOnly()(the exception is on the copy),.When("..."),.ModeBeforeConsumer()to run before the consumer gets the reply (default: asynchronously after). A handled exception is a completion. A failing block is logged, not thrown. Route-level or builder-level.Header / property / body sugar:
SetHeaders(("a", 1), ("b", expr), ...)— several headers in one step (constants,IExpression,Func<IExchange, object?>);RemoveHeaders("X-Internal-*", except: "X-Internal-Keep")andRemoveProperties(mask, except...)with exact / trailing-*/regex:masks;Sort("body.items", "body.priority")(the key expression sees each element asbody; numbers compare numerically across CLR types) and a typedSort<T>(source, key, descending, comparer)that leaves aList<T>in the body.DynamicEndpointResolver.ResolveUri(exchange)resolves aToDtarget without creating a producer;ToDynamicDefinition.Templateis public.AggregationStrategies— a library of ready-made aggregation strategies (CamelAggregationStrategiesparity). Each is theFunc<IExchange, IExchange, IExchange>the DSL already takes, so one strategy servesAggregate,Multicast,ScatterGather,SplitandEnrich:GroupedBody()/GroupedBody<T>(),GroupedExchange(),UseLatest(),UseOriginal(),Concat(separator),MergeHeaders(),IntoHeader(name),IntoProperty(key),Sum / Max / Min("body.amount")(route-language expression orIExpression),Custom(func). All tolerate the Split-stylenullfirst argument and the Multicast / Aggregate-style seeded first exchange.AggregationStrategies.ByName("intoHeader:customer")resolves the XML-form names..Aggregate(e => e.In.Headers["orderId"]!.ToString()!, AggregationStrategies.GroupedBody(), agg => ((List<object?>)agg.In.Body!).Count >= 3) .Enrich("sql:SELECT * FROM customers WHERE id = @id", AggregationStrategies.IntoHeader("customer")) .Multicast().AggregationStrategy(AggregationStrategies.Concat(",")).To("direct://a").To("direct://b").EndMulticast()Enrich(uri)/PollEnrich(uri, timeout)without a strategy. The response (or polled message) becomes the current message —UseLatest(), the Camel default; on a poll timeout the original stays. Dynamic-URI overloads too.Keyed throttle with a per-message limit.
Throttle(keyExtractor, e => limit, period)and the string formThrottle("header.customerId", "header.tier == 'gold' ? 100 : 10", period): key and limit come from the message, each key under its own gate.KeyedThrottleDefinition.MaxPerPeriodFactoryexposes it; the fixedintform is the constant case. Every key's gate is now the same arrival-order-fair, per-message-limit gate as the plainThrottle(ThrottleProcessor, one per key), so both throttles behave identically in wait and reject (429 +Retry-After) modes; idle gates are still evicted after two periods.Data formats beyond JSON/XML: four new packages plus byte wrappers in the core (Camel data-format parity). Every format is an
IMessageSerializer, so the existingMarshal/Unmarshalnodes,DataFormatRegistryand content-type-drivenUnmarshal<T>()/ConvertBody<T>()just work.Package Content type Library redb.Route.DataFormats.Csv—MarshalCsv()/UnmarshalCsv<List<Row>>(), POCO lists,List<Dictionary<string,string>>, rawList<string[]>; delimiter, header, quote, culture optionstext/csvCsvHelper redb.Route.DataFormats.Protobuf—MarshalProtobuf()/UnmarshalProtobuf<T>()for generatedIMessagetypes; optional Confluent wire framing (magic byte, schema id, message index)application/x-protobufGoogle.Protobuf redb.Route.DataFormats.Avro—MarshalAvro()/UnmarshalAvro<T>(), schema built from the CLR type or given as JSON; optional Confluent wire framingapplication/avroChr.Avro redb.Route.DataFormats.Yaml—MarshalYaml()/UnmarshalYaml<T>(),objecttarget gives a string-keyed treeapplication/yaml(+x-yaml,text/yaml)YamlDotNet core: Base64MessageSerializer,GZipMessageSerializer,ZipMessageSerializer(registered by default)application/base64,application/gzip,application/zipBCL .UnmarshalCsv<List<OrderRow>>(o => o.Delimiter = ";") // per-node options .Marshal("application/gzip") // by registered content type context.AddCsvDataFormat(); builder.AddAvroDataFormat(); // registration for content-type addressingEach package registers with
context.Add<Format>DataFormat()(orbuilder.Add<Format>DataFormat()in DI); the genericcontext.AddDataFormat(serializer)/builder.AddDataFormat(serializer)registers anyIMessageSerializer. Malformed input fails with an error naming the format. Schema Registry integration is not part of the formats (framing only).Marshal/Unmarshalby content type or serializer instance. New DSL:Marshal(string contentType),Marshal(IMessageSerializer),Unmarshal<T>(string contentType),Unmarshal(string, Type),Unmarshal<T>(IMessageSerializer),Unmarshal(IMessageSerializer, Type). A content type that is not registered failsStart()(not the first message).MarshalDefinition/UnmarshalDefinitionexposeContentType/TargetTypefor the XML form (<marshal format="text/csv"/>).Unmarshalnow also accepts astring(UTF-8) orStreambody — text formats almost always arrive as strings; before, onlybyte[]was unmarshalled and any other body passed through silently.redb.Route.Templates(new package): build a payload from a template — the WSO2 PayloadFactory / Camel templating analog, on Scriban 7. Astringis always a locator (file relative toRouteTemplateOptions.BaseDirectory, orassembly:Name/Path/file.sbn); inline text isTextSource.Inline(...);MediaType(Json/Xml/Text) is mandatory..SetBodyTemplate("Templates/order-confirm.json.sbn", MediaType.Json, a => a .Set("customer", "header.customerId") // route expression language, evaluated before the render .Set("total", "header.amount * header.qty") .SetValue("channel", "email")) .SetHeaderTemplate("X-Summary", TextSource.Inline("{{ body.items | array.size }} items"), MediaType.Text)- Compiled once at route build and cached by source: a missing file or a syntax error fails
Start()withTemplate 'Templates/x.sbn' (line,col): .... - Escaping of substituted values only, by media type (
"\control characters for JSON;& < > " 'for XML);{{ v | raw }}opts out for a pre-built fragment. Numbers and dates render culture-invariant (dates ISO 8601). Body getsContentTypefrom the media type. - Data model:
body(JSON / XML text parsed into a navigable tree —body.order.item[1].sku; a POCO with its C# member names),headers.name/headers["Content-Type"],properties,exception,args,expr("header.a * 2")(the same engine as${...}),raw(...). - Sandbox: no
include, no context / exchange objects, .NET objects expose public members only — a method call in a template fails the render.SetPropertyTemplatefor properties. - Options:
services.AddRouteTemplates(o => ...)orcontext.UseTemplates(o => ...)—BaseDirectory,Liquid(Scriban's Liquid-compatible syntax and filters),StrictVariables,LoopLimit. - XML form
<payload template="…" mediaType="json"><arg name="…" expr="…"/></payload>(inline text in the element,target="header:X"/property:X) is recorded in the Route-XML node catalog.
- Compiled once at route build and cached by source: a missing file or a syntax error fails
redb.Route.TestKit(new package): test a route without its brokers, without changing the route. Apache Camelcamel-testparity —AdviceWith, a richMockEndpoint,NotifyBuilderand one-line sends — with no test-framework dependency (assertions throwMockAssertionException).await using var ctx = new RouteContext().AddRoutes(new OrdersRoutes()); ctx.AdviceRoute("orders", a => a .ReplaceFrom("direct://test-in") // instead of kafka://orders .MockEndpoints("kafka://*", "sql:*")); // every matching To(...) goes to mock://kafka:... await ctx.Start(); var vip = ctx.Mock("kafka://orders-vip").ExpectMessageCount(1).ExpectHeader("priority", "high"); await ctx.SendBodyAndHeader("direct://test-in", order, "priority", "high"); await vip.AssertIsSatisfiedAsync(TimeSpan.FromSeconds(2));AdviceRoute(routeId, ...)/AdviceAllRoutes(...):ReplaceFrom,MockEndpoints/MockEndpointsAndSkip(the original is never called),WeaveById/WeaveByToUri/WeaveByType<T>()with.Replace / .Before / .After / .Remove,WeaveAddFirst,WeaveAddLast. Advice rewrites the definition tree betweenAddRoutesandStart(); the tree walk is the core'sOutputs+IBranchingDefinition.Branches, so aToinsideChoice,Split,Multicast,TryCatchor aCircuitBreakerfallback is reached without per-type code.NotifyBuilder:ctx.Notify().FromRoute("orders").From("kafka://*").Filter("header.kind == 'vip'") .WhenReceived / WhenCompleted / WhenFailed / WhenDone(n).Create()→await matcher.MatchesAsync(timeout).ctx.SendBody,SendBodyAndHeader(s),RequestBody<T>,RequestBodyAndHeaders<T>,ctx.Mock(uri)(accepts the original URI or themock://name;MockUri.For("kafka://x?acks=all")=mock://kafka:x).- URI masks everywhere: exact, trailing
*,regex:...; query strings ignored (UriMaskin the core).
Rich
MockEndpointin the core (CamelMockEndpointparity). Fluent, cumulative expectations:ExpectMessageCount,ExpectMinimumMessageCount,ExpectBodies,ExpectBodiesInAnyOrder,ExpectHeader(every message) /ExpectHeaderReceived(any),ExpectProperty,Expect(lambda),Expect(IPredicate),Expect("header.x == 'y'")(route language).AssertIsSatisfiedAsync(timeout)waits and fails withmock://x Received message count. Expected: 1 but was: 0plus every body received;AssertIsNotSatisfiedAsync,IsSatisfiedAsync. Scripted replies forEnrich/ request-reply throughmock://:Whenever(n)/WheneverAny()with.SetBody / .SetHeader / .Delay / .Throw / .Do. Expectations compare against the message as it arrived (a later step mutating the exchange cannot change what the mock saw);ReceivedExchangesstill holds the live exchanges. TheexpectedMessageCountURI option counts as an expectation.Step ids:
Id("...")DSL verb (Camelid()). Names the most recently added step (ProcessorDefinition.StepId) forWeaveByIdand diagnostics; before any step it names the route.Exchange-level lifecycle events.
IRouteLifecycleListener.OnExchangeReceived / OnExchangeCompleted / OnExchangeFailed(default no-ops), published by the outermost route wrapper so "completed" and "failed" are the final outcome after every error handler; a handled exception counts as completed. Zero cost when no listener is registered.ToDefinition.Uriis public;CircuitBreakerDefinitionimplementsIBranchingDefinition(its fallback body is now reached by the route validator and by AdviceWith).Trusted reverse proxies on the shared Kestrel host (
redb.Route.Http.Hosting). A proxy chain is a property of the process, not of a route: two routes on one port never sit behind different proxies. So the list now lives on the host, next to TLS and the protocol set, and every consumer on that host gets the client's address and scheme without doing anything itself: Http, Soap, As2, Grpc, and the Tsak management API all readConnection.RemoteIpAddressandRequest.Scheme, which the host rewrites before any of them runs.services.AddRedbRouteHttpHosting(o => o.TrustedProxies.Add("10.0.0.5").Add("10.1.0.0/16"));X-Forwarded-Foris walked from the right, past every listed proxy, to the first address that is not one: that is the client, whatever the length of the chain, because each proxy appends the peer it accepted from and a client's own entries always sit to the left of what the first trusted proxy wrote. A header from a peer that is not listed is ignored outright. An entry that does not parse stops the walk and keeps the socket peer; skipping it would carry the walk into the client-controlled part of the header.X-Forwarded-Protois read in step, soredbHttp.Urlcarries the scheme the client used: behind a TLS-terminating proxy that is what a DPoPhtucheck or a SCIMLocationheader must compare against, and until now it compared againsthttp://. The rewrite is off unless a proxy is listed, so a host with no configuration is byte-for-byte what it was.The walk is
ForwardedHeaderResolver, a pure function on strings with noHttpContextin it: the ten lines that know about Kestrel are the adapter inStartServer. This is the same shape asGrpcWire, protocol logic apart from whoever holds the socket, and it is deliberate: the resolver and its tests survive a change of HTTP engine unchanged. Not handled, by decision rather than omission: the RFC 7239Forwardedheader, andX-Forwarded-Host, whose rewrite changes what redirects and absolute URLs point at and needs an allow-list of its own.Prompted by a reader of the 3.7 release notes who pointed out that a right-most-hop rule, which
redb.Tsakused for its API-key throttle, names the next proxy rather than the client behind a chain such as Anti-DDoS to nginx, so every caller lands in one throttle bucket.redb.Identityalready carried the correct walk in a per-route processor; this moves it to the layer that owns the socket so no product has to carry its own. Covered by 19 unit tests on the resolver and 9 end-to-end through a real Kestrel andHttpConsumer, five of which fail on the host without the resolver.
Fixed
AMQP 1.0: producer
BytesOutwas permanently zero. The counter guarded onmsg.Body is Data, which is never true — AMQPNetLite'sBodygetter unwraps aDatasection to itsbyte[], and the producer's own message builder emits anAmqpValueanyway. Payload size is now read from the body section (Data, byte[] and string values).AMQP 1.0: a typo in
expiryPolicyfailed silently intosession-end— on a durable subscription that meant the broker dropped it between restarts. Unknown values now throw at endpoint creation, naming the option and the valid values.RabbitMQ / AMQP: a settle failure after a successful pipeline is a recorded error now. An ack/accept that failed once the pipeline had succeeded was invisible to both the core (Process already returned) and the connector (its catch only logged) — the message would be redelivered while
Errorsstayed at zero.IBM MQ
sslKeyResetCountreaches every connection path. It was wired into a factory method nothing calls at runtime; now the MQ-classes property builder that producers and poll consumers actually use setsMQC.SSL_RESET_COUNT_PROPERTY, and the XMS reply receiver setsWMQ_SSL_KEY_RESETCOUNTlike the consumer engine already did.IBM MQ
targetClient=Mqcovers the reply leg. The RPC reply used to attach RFH2/message properties even in Mq mode — the exact thing the option exists to prevent for legacy requesters. The classic consumer now skips the RFH2 copy and the XMS consumer stampsWMQ_TARGET_CLIENT=MQon the reply destination.POP3
fetchBody=falseon a server withoutTOP(the command is optional per RFC 1939) no longer degenerates into an endless generic retry warning: the consumer logs once that the server lacks TOP and falls back to full fetch.Mail: the body type is predictable from
HasAttachmentsagain. A mail whose attachments all failed to decode into the carried list (message/rfc822 parts, everything overmaxAttachmentSize) used to hand the route a bare string whileHasAttachmentssaid true — an unannounced contract change for routes that cast toMailMessageBody. WithfetchAttachmentson, an attachment-bearing mail always yields aMailMessageBody; the size cap drops payloads, not the shape.MailMessageBody/MailAttachmentare also deep-cloneable now, so a mail body can travel through a.Replayable()checkpoint snapshot.S3/IBM MQ docs: the S3 README's Aggregate replacement recipe now compiles (it named parameters that do not exist), and the leftover option tables for the removed Streaming Upload block and the removed
deadLetterQueue/maxRedeliveriespair are gone.SFTP: the
Windowsseparator value is gone (it shipped in this release cycle and never worked: the jail check, directory creation, and recursive listing all speak/— which is the SFTP protocol's separator; servers on Windows accept/too, so a backslash mode could never be honest, the same protocol-fact reasoning that removedbinaryandstepWise).separatorkeeps its two meaningful values:Auto(default; normalizes backslashes in configured paths and file names to/) andUnix(byte-for-byte). A URI that still saysseparator=windowsfalls back toAuto. The consumer's vanished-subdirectory guard is now a real TOCTOU guard: it swallowsSftpPathNotFoundExceptiononly when the subdirectory is actually gone — a path built wrong escapes loudly instead of becoming a silent per-poll skip.Kafka: commit-on-revoke could lose a consumed-but-unprocessed record. The revoked-partitions handler committed the consumer's position, which advances on
Consume— not on processing. Rebalance callbacks run insideConsume, so a rebalance during batch collection committed records the pipeline never saw; the new owner started past them. The handler no longer commits: every processed record is already settled inline (per message, or per partition at batch end), and anything consumed-but-unprocessed now correctly replays (at-least-once).Kafka
seekTois now a per-partition promise. The one-shot flag burned on the first assignment callback — which can be empty (more consumers than partitions), and underCooperativeStickypartitions arrive incrementally across several callbacks, so only the first increment was seeked. Each partition now seeks exactly once, on its first assignment to this consumer, and never again on a later rebalance (proved on a live cluster: an empty first assignment no longer swallows the seek, a three-partition topic seeks all three).Kafka: one fatal client error counted as 2–3 endpoint errors. The librdkafka error callback, the batch consume catch, and the poll loop each recorded the same event. The poll loop is the single owner now; the other two log only.
Kafka: factory credentials and
GroupIdactually work as defaults.Validate()and the consumer's groupId check see only the endpoint options, but the component copied nothing from the named factory except brokers — so "credentials via a named connectionFactory" (the error text's own advice) always threw, and the factory's documented defaultGroupIdwas unreachable.SaslUsername/SaslPassword/GroupIdnow land in the options before validation when the URI supplied nothing; the URI still wins.Statistics ownership, part two: the audit now covers every connector and every send path. The arc review found the "recorded exactly once, by the core" contract still leaking on side paths; all of them are closed:
- Six more connectors stripped of self-recording: AzureServiceBus (MessagesIn and the doubled pipeline error, in both consumers), SQS (consumer MessagesIn, pipeline error, producer MessagesOut), SNS, TCP, LDAP (seven per-operation MessagesOut), Telegram. The LLM scheduled consumer no longer records MessagesOut/ProcessingTime per tick and counts only ticks that die before the exchange reaches the pipeline.
- The sending EIPs — RecipientList, Enrich/PollEnrich, DeadLetterChannel, RoutingSlip,
DynamicRouter, and the dynamic
.To()— now record MessagesOut/Errors/ProcessingTime on their target endpoint through the same core funnel as a routed.To()and a template send (CountedSend). They used to bypass statistics entirely, so after the connector strip their sends would have counted zero. StatisticsProcessor(the consumer-side wrapper) no longer records MessagesOut on pipeline success. MessagesOut is a producer-side counter; on an in-memory bridge endpoint (direct:, seda:) the two roles are one object and the old double write made every bridged send count twice. A consumer endpoint's completed count isMessagesIn - Errors. On a failed bridged exchange, Errors still counts once per leg (send + pipeline) — exactly two.- gRPC consumer: a failure AFTER the pipeline succeeded (reply encoding/writing) is recorded again — the previous guard only kept pre-exchange failures, so a route that stably failed to write its replies looked healthy.
Secret redaction: three call sites bypassed the existing sanitizer. The core already masks userinfo passwords and sensitive query values (
EndpointUri.Sanitize+[Sensitive]), butHttpProducerlogged the raw resolved URL on every failed request,As2Producerbuilt its logged name and its HTTP-failure exception text from the raw partner URL, and the shared transport-span helper put the rawdestinationURL intomessaging.destination.namefor every connector's telemetry. All four now go throughSanitize(format-preserving: plain queue/topic names pass through byte-for-byte).HTTP
preserveHostHeaderno longer depends onbridgeHeaders. The explicit Host set was nested inside the header-bridge block, sobridgeHeaders=falsesilently disabled the option. They are independent, as in camel-http.AS2 producer no longer bridges the
Hostheader. AFrom(http)→To(as2)route used to forward the inbound request's Host to the partner — the same accidental preserve-host proxy that was fixed for the HTTP producer.Hostjoined the non-bridged (hop-by-hop) set.Statistics ownership: endpoint counters are recorded exactly once, by the core. Routed pipelines were already counted by the core —
StatisticsProcessorwraps everyFrom()processor (MessagesIn, BytesIn, Errors; see the part-two entry above for the MessagesOut refinement),ToProcessorcounts every routed.To()(MessagesOut, Errors, ProcessingTime) — while seven connectors (WebSocket, SignalR, SOAP, S3, Elasticsearch, Firebase, gRPC) and the LLM producer recorded the same numbers themselves. In a route that double-counted every message: the red-before e2e showed MessagesIn=2 for one WebSocket frame and MessagesOut=2 for one send. Meanwhile theProducerTemplatebypassed statistics entirely, so a template send was invisible unless the connector self-recorded — the inconsistency that made self-recording look necessary.The rule now has one sentence: the core counts the pipeline; a connector records only what the core cannot see. The
ProducerTemplatecounts its sends exactly like a routed.To()(MessagesOut, Errors, ProcessingTime — red-before: a template send recorded nothing). Connectors keep transport-level recording that no wrapper can observe: poll-loop and listener failures, pre-pipeline wire errors (a gRPC request that failed before an exchange existed, malformed SOAP/MTOM framing, a failed storage download), post-processing failures, producer wire bytes in both directions, and producer-side inbound operations (an LLM turn arriving, a Firestore get/query result, a storage download) asMessagesIn— no core wrapper counts those for a producer.Dashboards: numbers on routed endpoints of the seven connectors drop to their true values (half, for the doubled ones);
MessagesOuton LLM endpoints now comes from the route/template hop, not from inside the producer. Hand-built consumers and bareproducer.Processcalls outside any route or template record pipeline numbers nowhere — that is the ownership contract, pinned byStatisticsOwnershipTestsin the core and the per-connector*StatisticsOwnershipTests.The dead-options sweep, part B (docs/KAFKA_HARDENING_AND_OPTIONS_SWEEP_PLAN.md): 25 options across 9 connectors either work now or are gone. The F11 sweep covered connection factories; endpoint options had never been audited, and the test convention —
SeekTo_SetsParam-style assertions that an option lands in the URI query — let declared-but-never-read options ship documented, with DSL verbs, doing nothing. Every fix here is red-before, e2e against the live container park where one exists.Wired (now do what their docs always said): Kafka
breakOnFirstError,topicIsPattern(wave A2); HttppreserveHostHeader— and the bridge no longer leaks the originalHostinto every proxied request unconditionally, which made everyFrom(http)→To(http)route an accidental preserve-host proxy (migration: proxy routes relying on that leak now setpreserveHostHeader=trueexplicitly); FtptransferType(ASCII/Binary reach FluentFTP); Sftpcompression(zlib@openssh.com preferred, none kept as fallback),separator(Auto/Unix/Windows shape remote paths, Auto normalizes backslashes),directoryMustExist(a subdirectory vanishing mid-listing is skipped by default, loud when true); MailfetchBody(real envelope scanning — IMAPGetHeaders/POP3GetMessageHeaders, the body never travels),fetchAttachments(metadata stays, payload does not),maxAttachmentSize(caps the decoded copy handed to the route; the MIME is already in memory, stated honestly in the doc),mapMimeHeaders(raw MIME headers underredbMail.Mime.*); IbmMqtargetClient(Mq= raw MQMD+body, no message properties at all, so nothing materializes as MQRFH2 for a legacy app) andsslKeyResetCount(both the MQ-classes and the XMS path); AmqpterminusTimeoutandexpiryPolicy— the latter had a resolver nobody called — both ride the Source/Target terminus now, defaults matching AMQP 1.0 so existing configurations behave identically.Removed (breaking, 4.0.0 bundle) — because they cannot mean anything or duplicate an implemented mechanism: Sftp
binary(SFTP has no text mode; the protocol moves raw bytes) andstepWise(SFTP has no change-directory operation — SSH_FXP requests carry paths, a client-side "cd" is bookkeeping, so the FTP notion does not map; directory creation already walks segment by segment); IbmMqdeadLetterQueue+maxRedeliveries(a second, dead vocabulary for poison handling next to the implemented nativebackoutThreshold/backoutQueue); KafkatransactionIdPrefix(fed a setting deliberately never configured); and the entire S3 Streaming Upload block —streamingUploadMode,batchMessageNumber,batchSize,bufferSize,streamingUploadTimeout,namingStrategy, the.StreamingUpload()DSL verb and theS3NamingStrategyenum: six options and a README section promised a camel-style accumulate-and-flush mode of which not one line was implemented. Accumulate-then-write belongs to the route in this framework — the coreAggregate(...)EIP completes by count, size and timeout; the S3 README now shows that recipe. Old URIs carrying any removed parameter do not break: unknown parameters land inUnmappedParametersand are ignored.Two sweep lessons are recorded in the plan: a read inside the options file itself (a
Validate()check, an uncalled resolver) masks a dead option from the scan — that is how three of S3's six and Amqp'sexpiryPolicyhid; and per-connector poison/batching vocabularies keep reappearing next to core EIPs that already do the job.redb.Route.Kafka: the hardening sweep (docs/KAFKA_HARDENING_AND_OPTIONS_SWEEP_PLAN.md, waves A1–A7; every fix red-before, e2e against a live 3-node KRaft cluster).A typo in an enum-valued option fails loud now (A1).
Enum.TryParseused to swallow the assignment, sosecurityProtocol=SaslSSLx— or the canonical Kafka spellingsSASL_SSLandSCRAM-SHA-256, which TryParse does not know — silently yielded a plaintext connection with no credentials. Every enum option (acks,isolationLevel,autoOffsetReset,compressionType,partitionAssignmentStrategy,seekTo) now parses strictly with the option name and valid values in the error;_/-separators are accepted, so both the C# and Kafka spelling families work. A password-carrying SASL mechanism without credentials is refused (Gssapi/OAuthBearer exempt). Same fix inredb.Route.RedisforsslProtocols. Migration: configurations that only worked because a typo fell back to a default now fail at startup — that is the point.A message whose processing throws is no longer lost (A2). The escaping exception used to reach the poll loop and the next successful commit covered the failed record's offset — a Kafka commit is a position, not a per-record mark. Default: move on explicitly (logged, counted; Camel's default too).
breakOnFirstError=true— the option existed in URI/DSL/README and was read by nothing — now implements Camel semantics: seek back to the failed record and retry.topicIsPattern=true(also dead) now supplies the^prefix librdkafka's regex subscription keys on; combined withpartitionNumberit is refused.transactedno longer promises exactly-once (A3, breaking). The honest story was always in docs/KAFKA_TRANSACTIONS_TODO.md (idempotent producer + deferred send, at-least-once, notransactional.id); README and the IntelliSense docs said "exactly-once semantics". They agree now, andTransactionIdPrefix+Transacted(idPrefix)are gone — the value fed a setting that is deliberately never configured, so it was silently discarded. A URItransactionIdPrefix=now lands in UnmappedParameters and is ignored.seekToworks for group consumers (A4). It readconsumer.Assignmentright afterSubscribe(), when the group has not joined and the assignment is empty — a silent no-op everywhere except withpartitionNumber. The seek now rides the partitions-assigned handler, once, on the first assignment; a later rebalance resumes from committed offsets (pinned by an e2e test that adds a second group member).Statistics without double-counting (A5). Discovery (the plan’s principle 0): pipeline statistics for routed endpoints already come from the core —
StatisticsProcessorwraps everyFrom()processor,ToProcessorcounts for every.To(). The connector now records only what the core cannot see: transport-level poll errors and the producer's wireBytesOut. Flagged for the owner: six connectors that self-recordMessagesIn/BytesInin consumers likely double-count in routed mode.Factory vs URI resolves by the family rule (A6). A parameter actually supplied in the URI wins; everything else keeps the factory value. Before: URI brokers honored, URI SASL/SSL silently swallowed, endpoint defaults (
autoOffsetReset=Latest,retries=3) stomped explicit factory settings, URIacksignored entirely. A multi-partition batch now commits the last offset of every partition it touched, not just the last record's (the revoke handler used to paper over it at clean shutdown; kill -9 replayed far more than needed). The revoke-commit catch ignores onlyLocal_NoOffsetand reports everything else. Topic metadata logging uses the factory'sBuildAdminConfig()(it existed, with full security, and was called by nothing), prints one line instead of one per partition, and is skipped when Information is off.Smaller (A7): consumer receive spans root correctly when no
traceparentarrives (theStartActivity(parentContext: default)gotcha — it inheritsActivity.Current); batch exchanges restoreContentTypelike single-message ones; a fatal librdkafka error stops the poll loop withLogCriticalinstead of retrying forever;ProcessedCountis thread-safe; factorySaslPassword/SslKeyPasswordare[Sensitive];sslEndpointIdentificationAlgorithmis available on the endpoint URI, not only the factory.redb.Route.Tcp:localhostin a consumer URI crashed the start, and the producer could not reach a self-signed server (red-before).IPAddress.Parse(_options.Host)threw a bareFormatExceptionon any name — and the producer side of the same connector resolves names happily, so one URI worked as a client and crashed as a server. The host is resolved now (an IP,0.0.0.0,localhost, or a DNS name), with an error naming the host when it resolves to nothing. ATcpListenerbinds one address, solocalhostmeans the IPv4 loopback here — stated in the README, unlike the shared HTTP host, which binds both because Kestrel opens two listeners for it. Separately,TcpProducerhad no way to accept a self-signed certificate at all (AuthenticateAsClientAsync(host)with no callback), which made a staging server unreachable by construction; the explicittrustAllCertificatesnow present on ws, SignalR and S3 is available here too, on the endpoint, onTcpConnectionFactoryand as a DSL verb. The producer also gainedclientCertPath/clientCertPassword(.ClientCert(path, password)), so a server that requires mTLS is reachable at all — the naming follows the gRPC connector, and the certificate is loaded once at start rather than per connection. The consumer still does not request client certificates (AuthenticateAsServerAsyncwith the server certificate alone), which is a separate feature rather than a defect.redb.Route.Tcp: a TLS listener without a certificate now refuses to start (red-before). It used to bind and accept, then kill every accepted connection deep in the accept loop with anArgumentNullExceptionon a null-forgivenSslCertPath!— a type its catch list (OperationCanceledException,IOException,SocketException) does not even cover — while the port sat there looking alive. No plaintext ever flowed (the stream becomes anSslStreamonly after a successful handshake, and the read loop is inside the sametry), so this was a failure mode and diagnostics defect rather than the AS2-class hole; it is now aligned with the shared host all the same. The certificate is also loaded once at start instead of being re-read from disk on every accepted connection — a file read and a PKCS12 parse leave the hot accept path, connections stop disagreeing about which certificate is current when the file is replaced under a running server, and theX509Certificate2(previously created per connection and never disposed) is released on Stop. A bad path or a wrong password is a start-time error too. The check lives at consumer start rather than inValidate()on purpose:Sslis shared with the producer, which legitimately needs no local certificate.HttpProducerprinted the URI password into the log.ProducerNamebuilt the "producer started" line from a rawBuildProducerUrl(), sohttp://user:pass@hostcarried its password there. Now redacted throughEndpointUri.Sanitize, as the WebSocket and SignalR producers already are. Worth noting which half of the machinery applies:[Sensitive]marks option properties and covers secrets travelling as query parameters, while a userinfo password sits in the authority where no attribute reaches it —Sanitizehandles both.TLS without a certificate no longer opens a plaintext port anywhere (F14 addendum, red-before).
SharedHttpServerManagerdecided TLS withentry.Ssl && certPath != null, and theelsebranch opened an unencrypted socket whileGetBaseUrland every log line reportedhttps://. That is not "TLS off" — it is a silent downgrade an operator cannot see, and every comparable stack refuses to start instead: nginx (no "ssl_certificate" is defined), httpd, Jetty, Spring Boot, and Kestrel's ownUseHttps(). The host now does the same.redb.Route.As2was the live victim. Its consumer passeduseTlsto the shared host and no certificate with it — the connector had no server-certificate option at all — so anas2s://receiver listened in plaintext whileAs2Endpointadvertisedhttps://to the trading partner as itsPartnerUrl. Fixed withsslCertPath/sslCertPasswordonAs2EndpointOptionsand onAs2ConnectionFactory(where the password belongs), aTls(...)verb in the DSL, and the same wiring in the async-MDN receiver. Both the message receiver and the MDN receiver now hand the certificate to the listener; without one they refuse to bind.Where the certificate comes from is a separate question from whether it exists, so the host gained the missing link:
AddRedbRouteHttpHosting(o => o.Tls.DefaultCertificatePath = ...)(oro.Tls.DefaultCertificate), resolved after the endpoint and its connection factory. This is the shape Camel gives globalSSLContextParametersand Spring Boot gives SSL bundles — a certificate on the endpoint is an override, not a requirement. Nothing about it turns TLS on;sslstays an explicit per-endpoint decision.Consequently the eager per-connector checks are gone:
HttpEndpointOptions.Validate()andGrpcEndpointOptions.Validate()no longer rejectSslwithoutSslCertPath(they could not see the factory or the host default), and the equivalent checks added to the WebSocket and SignalR consumers earlier in F14 are removed. One invariant, enforced once, at the bind, by the only place that sees all three sources. A route that used to fail at build time now fails at start with a message naming the listener and every place a certificate could come from.A hub is an occupant of the shared listener, not a guest of it (F14 wave 7, review of the phase itself —
docs/V4/REVIEW-WS-SIGNALR-DONE.md). Three defects that the migration to the shared host introduced, all red-before: (1)StopIfEmptycounted only routes, and a hub registers none, so stopping the last HTTP route — or the first of two hubs — closed the socket under a hub that was still serving; (2) the hub's registration was one-shot per endpoint, so a consumer that stopped could not start again (No routes registered for host:port), a regression against the old behaviour where every start raised its ownWebApplication; (3) hub dispatch was keyed on the request path alone, so one component serving/hubon two ports answered both from whichever consumer registered last.RegisterServerConfiguratorreturns a handle now,UnregisterServerConfiguratorgives it back,StopIfEmptywaits for routes and configurators both, and the dispatch key carries the listener's port. Also from the same review:localhostgoes through Kestrel'sListenLocalhost, which binds both loopbacks, rather than being mapped to127.0.0.1— a client that resolved it to::1would not have connected.messagePack=trueregistered the JSON protocol (F14 wave 4). The implementation wasif (options.MessagePack) signalRBuilder.AddJsonProtocol()— the option turned on JSON under a MessagePack name, and the protocol package was not even referenced, so a client that negotiated MessagePack could not connect while three places in the documentation promised it worked. Both protocols are registered now (JSON stays the base, so existing clients are unaffected), with an e2e test that connects a realAddMessagePackProtocol()client. Pulling the package also pulled vulnerable transitiveMessagePack 2.5.187(NU1903), soMessagePack 3.1.8is pinned explicitly and the protocol package is10.0.11—10.0.3itself carries GHSA-f8h2-vmm9-qhj6.wss://andssl=truewithout a certificate opened a PLAINTEXT socket (F14 wave 2, both connectors). The consumers branched onssl && certPath != nulland fell through tokestrel.Listen(...)without HTTPS, whileBuildBaseUrl()and the startup log reportedwss:///https://— a configuration that should have refused to start instead opened an unencrypted port and announced it as secure. Both consumers now fail loud, naming the option to set. (TheSslflag is the same for producer and consumer, so the check lives at consumer start rather than inValidate(), where it would breakwss://clients.)SignalR turned certificate validation OFF whenever
sslwas not set (F14 wave 2). The producer'sif (!_options.Ssl)installedDangerousAcceptAnyServerCertificateValidator, on the theory that "TLS was not asked for, so there is nothing to check". ButHttpMessageHandlerFactoryapplies to the whole SignalR transport, negotiate and redirects included, so anhttp://hub that answered with a redirect tohttps://was accepted with any certificate at all. Replaced by an explicittrustAllCertificates=true, the same shape S3 uses; the WebSocket producer, which previously could not disable validation at all and so could not reach a self-signed staging server, gained the same option. Both are covered against a live self-signed listener. (Along the way: SignalR needsWebSocketConfigurationas well asHttpMessageHandlerFactory— the WebSocket upgrade runs on its ownClientWebSocket.)A server-mode producer could never find its consumer (F14 wave 6, both connectors). The consumer registry was keyed on
EndpointUri.NormalizedKey, which includes the sorted query parameters — and a server-mode producer carries at leastmode=server, so its key never matched the consumer's. The key is the listener address plus the path now.A SignalR hub with
ssl=trueserved plain HTTP after the shared-host migration. A hub maps itself through a configurator and never callsRegisterRoute, so its TLS settings had no way to reach the listener;RegisterServerConfiguratortakes them.localhostin a consumer URI crashed the shared host.IPAddress.Parse(entry.Host)threw a bareFormatExceptionfor a name people write in consumer URIs constantly. The bind address is resolved now —localhost, an IP, or a DNS name — with an error that names the host when it resolves to nothing. Additive: what used to be a crash now works.Endpoint statistics were not recorded by either connector, and producer names leaked URI passwords into the log. Consumers record
MessagesIn/BytesIn/Errors, producers recordMessagesOut/Errors, so health and dashboards stop showing a live endpoint as idle; producer names go throughEndpointUri.Sanitize, sows://user:pass@hostno longer prints its password in the "producer started" line. The WebSocket producer span also carriesmessaging.destination.name, which SignalR had and it did not; SignalR'sStopdrains in-flight exchanges like the WebSocket one instead of relying on a 5-second host shutdown; and an unknownencoding=fails inValidate()with a message naming the value, instead of a bare frameworkArgumentExceptionfrom a constructor.A typo in
connectionFactory=…fails loud in every connector (F11 wave Zh, red-before on the reference set). ~20 connectors resolved the named factory with a silent (or warning-level) fallback to URI parameters/defaults when the name was not in the registry — a misspelled name meant quietly connecting to the wrong broker with inline credentials. The new core helpercontext.GetRequiredFromRegistry<T>(name)throws with guidance instead, and all factory-resolve sites use it (Llm keeps its own lazy chain — it has no URI fallback to hide behind). Bonus: Kafka resolved the factory only AFTERValidate(), so a factory as the only source ofbrokersnever worked — resolution moved before validation (the Telegram pattern). The old "falls back to URI parameters" unit specs were rewritten to expect the loud failure.S3 connection options finally reach the AWS SDK;
S3ConnectionFactory.Build()with a custom URL works at all (F11 wave S-B, red-before).SocketTimeout(→ requestTimeout;ConnectionTimeoutnow maps toConnectTimeout),RetryMode(standard/adaptive) andTrustAllCertificates(customHttpClientFactoryfor self-signed MinIO/Ceph) were declared, bound — and never applied. Bonus: in SDK v4 assigningRegionEndpointandServiceURLclears the other, so the factory's "set region, then null it for ServiceUrl" dance left BOTH empty —Build()with a MinIO URL always threw "No RegionEndpoint or ServiceURL configured".ConditionalWriteis implemented as a conditional PUT (If-None-Match: *, create-only semantics).S3 consumer resource hygiene (F11 wave S-V). Unsorted polls stream the listing page by page with an early exit at
MaxMessagesPerPoll— no more O(N) memory per poll on big buckets (sorting still honestly buffers). The idempotent repository got double-buffer eviction at 10K entries instead of unbounded growth. Buffered downloads dispose theGetObjectResponse(the HTTP connection was leaked until GC), a streamed body's ownership is documented (the exchange disposes it), andIncludeBody=falsenow truly skips the content download instead of leaking a raw response stream into the body. A failed delete-after-copy in MoveAfterRead logs the duplication instead of failing the poll.S3Producer(890 lines) was split into three partial files per the "one type - up to ~400 lines" rule.S3 consumer no longer loses objects (F11 wave S-A, all red-before against MinIO — findings S-1/S-2/S-4 of docs/V4/REVIEW-S3.md). Three data-loss paths closed: (1) a raw processor throw was swallowed by the drain-guard and the object was still deleted/moved — with
DeleteAfterRead=truebeing the DEFAULT, a failed exchange silently destroyed its object; post-processing now requires an actually successful exchange (no raw throw AND no unhandledexchange.Exception). (2) The idempotent mark was written at FILTER time — objects beyondMaxMessagesPerPollwere marked "seen" without a single processing attempt and never offered again, and a failed object was never retried; the mark is written only after success now. (3) Consumer exchanges were built without the endpoint'sScopeFactory, so per-exchange DI scopes silently never worked for S3 routes.Firebase telemetry and statistics gaps (F11 wave V, red-before). Storage spans carried
db.system=gcs— GCS is not a database; object storage now uses the house attributeredb.system(gcs), aligned with the S3 sibling (redb.system=s3). FCM spans finally carrymessaging.destination.name(topic/condition; a device token is a secret and goes in only as the literaltoken). The declared-but-never-readFcmHeaders.ImageUrlheader now reaches the notification. Producer-Download recordsBytesInlike S3 does — a download-heavy endpoint no longer looks idle on the dashboard.Firestore
Realtime=falseis an honest poll loop now (F11 wave G, red-before). The option existed,Validate()policedDelay, DSL exposed.Realtime(false)/.Delay(ms)— and the consumer always started the snapshot listener anyway. A dedicated polling consumer runs the query everyDelayms (InitialDelayhonored) and diffs results byDocumentId → UpdateTimeintoAdded/Modified/Removed(removed docs carry anullbody; the diff is in-memory, a restart re-delivers asAdded— same as the listener's initial snapshot).Firebase never occupies the process-global
[DEFAULT]app (F11 wave G, red-before).FirebaseApp.Create(options)claimed[DEFAULT], so a host that initialized Firebase Admin itself collided with the connector, a second credential provider threw "already exists", andDisposedeleted an app the connector did not own. Apps are named per(credentialPath, projectId)now — several service accounts coexist in one process and only our own apps are deleted.Missing Firestore project id fails loud (F11 wave G, red-before). The provider silently fell back to a made-up
"default-project"; now it throws with the four ways to configure it (?projectId=…,FirebaseOptions.ProjectId— which finally reaches Firestore/Storage, not just FCM —DefaultProjectId,FIREBASE_PROJECT).Firebase Storage URI prefix is folder-like (F11 wave G, red-before).
fbstorage://bucket/uploads+file.txtproduceduploadsfile.txt; the path after the bucket now always means theuploads/folder (a raw string prefix remains available via theprefixoption).Every connector's
AddRedbRouteX()finally registers its components in a hosted context (F11 wave B, all 28 packages). Registration hung on a lazyI<X>ComponentRegistrarsingleton that no production code ever resolved —AddRedbRoute()+AddRedbRouteKafka()compiled, started, and then failed the first route with "No component registered for scheme". Real hosts survived only by callingcontext.AddComponent(...)by hand, and unit tests resolved the markers explicitly ("// trigger registrar"). All connectors now register throughIRouteContextConfigurator— the hookRouteHostedServiceactually applies at startup (the pattern Telegram already used); the marker interfaces are gone. Acceptance tests added per connector: the documented two-line registration now genuinely resolves the scheme.Firebase Storage consumer no longer loses objects whose processing failed (F11 wave A, all red-before against the live emulators).
DeleteAfterRead/MoveAfterReadfired unconditionally after the exchange — but the drain-guard swallows processing exceptions, so a failed object was deleted or moved into the success pocket, and withIdempotentit was also marked "seen" before processing and never retried. Post-processing now runs only when the exchange actually succeeded (no raw throw AND no unhandledexchange.Exception), the idempotent mark is written after success, and the newMoveFailedprefix quarantines poison objects instead of retrying forever.Firebase
credentialPathis honored by Firestore and Storage (F11 wave A). The option was validated, documented and bound — and then ignored:FirestoreDb.Create()/StorageClient.Create()always used Application Default Credentials. Both clients are now built through the SDK builders withCredentialsPathand cached per(projectId, credentialPath); emulator detection (FIRESTORE_EMULATOR_HOST,STORAGE_EMULATOR_HOST) finally works through the shipped provider — the docs promised auto-detection the SDK never performed, andValidate()accepted aFIREBASE_STORAGE_EMULATOR_HOSTvariable this SDK does not read (renamed to the realSTORAGE_EMULATOR_HOST).A dead Firestore listener is observed instead of silently ending the consumer (F11 wave A, red proven by reverting the monitor). Nobody awaited
FirestoreChangeListener.ListenerTask, so a permanent stream failure (revoked credentials, deleted project) left a zombie consumer with clean statistics. A monitor task now records the error on the endpoint and re-creates the subscription with exponential backoff (1s → 60s cap, reset after a stable minute).Processors owning background resources are finally disposed with the context (review of the Route-XML F1 work, red-before). Nothing had ever disposed compiled processors: the context's
DisposeAsyncreleased components only, soResequencerProcessor.DisposeAsyncwas dead code since the day it was written, and the new aggregator inactivity-timeout timer (F1.3) outlived the context — a pending group was flushed into a stopped pipeline by a background timer that kept firing forever.CompileNode— the seam every compiled node flows through — now registersIAsyncDisposableprocessors, and disposing the context releases them before the components (a processor may still hold component resources). Deliberately not onStop(): a stopped context can start again and reuses its compiled processors; between Stop and Dispose a late flush hits an unregistereddirect:and lands on the exchange, the resequencer's established shape. Proven red-before: a pending group no longer flushes afterDisposeAsync. The same review also fixed a vacuously green assertion in the phase's own tests (an un-awaitedThrowAsync); full findings indocs/Route-XML/REVIEW-F1-2026-09-02.md.redb.Route.Llmstorage: every business key is now guarded by the object unique key (docs/LLM/STORAGE_UNIQUE_KEYS_PLAN.md). All redb-backed LLM stores used the same pre-V4 check-then-save on_objects.value_stringwith no unique index anywhere, so two concurrent writers of one key both inserted and readers picked an arbitrary row. The worst case split a conversation in two: concurrent appends to a new conversation each minted their own root, and transcripts silently lost the other half. A concurrent first budget write split the cost accumulator across rows, undercounting the spend limit; prompt-template versions, knowledge chunks, cache entries, batch jobs, eval runs and approvals duplicated the same way. Every creation now also writes the key (normalized by the sharedRedbUniqueKeyhelper, extracted from the idempotent-repository fix) into_objects._value_unique, and the loser of a race catches the typedRedbUniqueViolationException: conversation roots, budget rows, template versions and approvals resolve first-wins (the winner's row is adopted, never overwritten); cache entries, knowledge chunks, batch jobs, eval runs and tool-idempotency outputs re-apply onto the winner's row (last-writer-wins upserts); the bulk paths (UpsertManyAsync,RegisterManyAsync,SaveManyAsync) rebuild from a fresh lookup and retry once. Lookups stay onvalue_string, so rows created by older versions are still found and no backfill is needed. Along the way the conversation root's token counters moved underExecuteAtomicAsync+LockForUpdateAsync(parallel appends lost increments), and the store doc-comments stop claimingvalue_stringis indexed on MSSQL — NVARCHAR(MAX) is unindexable there and those lookups scan the scheme; the full-text index covers_values._StringforCONTAINS, not equality. All thirteen race tests proven red on the un-fixed code in a worktree.An adversarial review of this work then found and fixed five defects of its own (each proven red on the pre-review code where testable): duplicate ids inside ONE bulk call self-collided on the new unique key and crashed the retry with a bare
ArgumentException, losing the whole batch — the input is now deduplicated, last entry wins; the budget'sReset×Addinterleave lost the restructured self-healing and threw away the delta — restored with a bounded retry loop; a raced batch re-register could drag a webhook-written terminal status back to "submitted" forever — a terminal status is never regressed now (the comment also lied: the webhook never registers, the real racer is a redelivered submit); the under-lock re-reads usedredb.LoadAsync, which the props cache can answer with zero DB reads (SkipHashValidationOnCacheCheck), silently losing another node's increments — they re-read by query now, which always hits the row; andRedbUniqueKey.Normalizecould split a surrogate pair at the 375-char cut, producing a key no provider can store. Core-side observations went todocs/BUG_REDB_CORE_CACHE_AND_AMBIENT_TX.md(the zero-DB cache shortcut vsLockForUpdateAsync, unique-violation recovery vs an enclosing PG transaction —SaveByUniqueAsync's own retry included, shared cached Props instances).The core closed that report the same day (savepoints around saves under a caller's transaction, no zero-DB cache shortcut inside a transaction, the cache's dirty-snapshot guard pinned by a test), which lifts the last limitation of the barrier: the catch-and-recover paths of these stores — and of
RedbIdempotentRepository— are now legal inside.Transacted()routes and any enclosingExecuteAtomicAsync. The store-level contract is pinned byToolCache_SetRace_InsideCallerTransaction_RecoversAndTransactionSurvives: the loser's violation fires inside the caller's transaction, recovery lands on the winner, and later writes in the same transaction still commit (before the core fix this died with PostgreSQL 25P02). The under-lock re-reads keep their query form as a guarantee independent of cache mode and core version.An unhandled exception in a controller action no longer travels to the caller (
redb.Route.Controllers). A dispatcher answered 500 with the exception's ownMessage— text written by whoever threw it, routinely carrying a file path, a connection string or the name of an inner service, handed straight to the caller. It was written nowhere else, so the operator could not even see what the caller saw. The caller now gets a generic sentence with the exchange id to quote, and the exception goes to the log under the same id.The report named one dispatcher; the package has five, and four ran user action code with their own copy of the same line:
ControllerDispatcherProcessor,HttpControllerDispatcher,GrpcControllerDispatcher,SignalRControllerDispatcher. All four now go through oneControllerErrorReporting.Report(...)— a copy is exactly how one of them stays wrong after the others are fixed.The dispatchers were not the whole of it either: the transports leaked the same text either side of them, and an adversarial review of the first fix found all three.
HttpConsumerwroteexchange.Exception.Messageas the 500 body for any exception escaping a route — with or without a controller.SoapConsumerput it infaultstringwhenever the failure was not a deliberateSoapFaultException(a route that raises one still has itsFaultStringsent verbatim: that text is chosen, and WS-Trust clients branch on it).GrpcWire.FromExceptionreturned it as the status detail for anything it did not map, so it travelled in thegrpc-messagetrailer. All three now emit the same generic sentence with the exchange id; statuses the framework itself chose — anRpcExceptionthe route raised, a protocol error, a deadline, a cancellation — keep their text.Framework-generated errors keep their text (400 for a missing header, 404 for no matching action): that text is ours and says nothing private, and it is now pinned by a test — without one, genericising those too would have passed every leak assertion while making the API harder to use. Both exception paths are covered — a sync action, whose exception arrives wrapped in
TargetInvocationException, and an async one, whose faulted task rethrows the original — and the tests that pinned which exception is selected now read it from the log, where it went, instead of from the response, where it no longer is. Two dispatchers were still selecting it withex.InnerException ?? ex, the pattern the other two warn against in comments: on the async path that logs a deeper transient wrapper (aSocketExceptioninside aDbException) instead of the failure the action reported — harmless while the message also went to the caller, silent corruption of the only remaining record once it does not.The exchange id is the compensating control for withholding the detail, so it is pinned too: the caller's message carries it and the log line carries the same one. And it is found even when the host configures logging only through DI —
IRouteContext.GetService<T>()reads the context's own service table, not the container, so a dispatcher that stopped there would silently have no logger and lose the exception entirely, which is worse than the disclosure it replaced. A filter that throws is logged asIControllerActionFilterdocuments, instead of vanishing into a barecatch. Reported as BR-4 inredb.Tsak/docs/BOUNDARIES_AND_FOLLOWUPS.md§1.A SOAP request the consumer could not read blamed the wrong party, and a decryption failure said why (
redb.Route.Soap). Three rejections that happen before an exchange exists — a malformed MTOM wrapper, a failed WS-Security decryption, a malformed envelope — all answered with the default fault code,soap:Receiver(soap:Serverin 1.1). SOAP 1.2 defines that value as a failure "attributable to the processing of the message rather than to the contents of the message itself", which callers and retry handlers read as retry: the framework was telling a client to re-send bytes that can never succeed. All three now answersoap:Sender/soap:Client.How much to say about why is a separate question, and the answer differs per site. The two parse failures keep their text:
XmlExceptionreads "'<' is an unexpected token. Line 1, position 5." andSoapMultipartthrows our own wording about the caller's own framing. Both describe the bytes the caller sent, not anything on this side, and they are the one clue that finds a BOM, a wrong encoding or a truncated stream — withholding them would buy no secrecy and cost the integrator the diagnosis.The decryption failure is the opposite case and now says nothing.
CryptographicExceptiondescribes our key and our configuration, and text that varies with the cause is a decryption oracle. WS-Security is explicit that a fault here "could be used as part of a denial of service or cryptographic attack", and defines exactly one code for every cause:wsse:FailedCheck, "the signature or decryption was invalid". The caller now gets that code and one fixed sentence carrying the request id to quote; the exception goes to the log, where the operator can read it and the attacker cannot. A test posts two genuinely different failures — a key we do not hold, and ciphertext tampered with after encryption — and requires the two answers to be identical once the request id is masked.No opt-in switch was added, and that is deliberate. CXF (
exceptionMessageCauseEnabled, off "due to security consideration") and WCF (IncludeExceptionDetailInFaults, off, "recommended only as a way to temporarily debug a service application") both gate the service operation's exception, which is the path already closed by the entry above. What is withheld here is the reason a decryption failed, and that is precisely the thing that must not be switchable back on: an option nobody may safely enable is a trap, not an option.Left open on purpose: SOAP 1.2 puts application-specific codes in
Subcodebeneath one of its fiveValues, whileSoapEnvelope.BuildFaultwrites any code straight intoValue. That predates this change and applies equally to the codes a route picks throughSoapFaultException(wst:FailedAuthenticationand the like), so it is a decision of its own rather than a wire-format change smuggled in here.A route span carried no route id (
redb.Route).InstrumentedProcessorreadsexchange.RouteIdwhen it opens the span, but the id is stamped by the first step of the route pipeline — which that span already wraps — soredb.route.idwas empty on every route-level span, on both the success and the failure path. The span name carried the route, the tag did not, and the tag is what a backend filters and groups by. It is now set once the id is known, including in the failure branch, where an unattributable span is exactly the one an operator is looking for. Found by a positive control in the secret-leak regression: the assertion "spans do mention this route" failed, which the previous negative-only assertions could never have shown.RedbIdempotentRepositorydid not survive a race on one key (docs/BUG_IDEMPOTENT_REPOSITORY_UNIQUE_RACE.md). The check-then-save on_objects._namehad a TOCTOU window: two concurrentAddcalls for one message both saw "new" and both inserted — the guarantee broke exactly in the case the repository exists for (and with redb.Identity's guard index deployed, the loser crashed on a raw driver error instead). An entry now also writes its composite identity intoValueUnique(_objects._value_unique, guarded by the core's per-scheme unique indexUIX__objects__scheme_uniqueon PostgreSQL, MSSQL and SQLite); the loser's typedRedbUniqueViolationExceptionis treated as "already processed" — first wins, at-most-once holds across threads and cluster nodes. Lookups stay on_name, so entries created by older versions are still found and no backfill is needed. A composite longer than 440 characters (client-supplied keys) is normalized to a readable prefix plus a SHA-256 tail — this also fixes long keys overflowing MSSQL's_name nvarchar(450), which failed outright before. The repository's higher deadlock-retry budget (5 retries / 200 ms base), declared and documented since the constants were introduced, is now actually passed toDeadlockRetryHelper— the calls silently used the 3 / 50 ms defaults.Code review of the 4.0 work, first batch (
docs/V4/REVIEW-CODE-2026-09-01.md).OnCompletionreleased its copy withDisposeAsync, and the copy shares its body with the live exchange (Message.Clonecopies the reference): aStreambody was closed under the consumer, synchronously when aWhencondition was false. The copy now releases only its DI scopes (ReleaseScopes, as the Multicast / Splitter clones do).- The after-consumer
OnCompletionblock ran on a bareTask.Run: the caller's ambient transaction flowed into it and completed underneath it, and its telemetry hung off a stopped span. It now runs in a detached frame like WireTap (DetachedDispatch: transaction suppressed, span re-rooted and linked), and the copy no longer shares the route's deferred transport actions. - A
Whencondition ofOnCompletionthat threw turned a successful exchange into a failed one and, on the failure path, replaced the route's own exception. The condition is evaluated inside the block's own guard: logged and skipped, the route's result untouched. InterceptSendToEndpointpublished the raw target URI inredb.toEndpoint/CamelToEndpoint, credentials included; the headers now carryEndpointUri.Sanitize(uri). Matching still uses the raw URI. (One site of BR-3 inredb.Tsak/docs/BOUNDARIES_AND_FOLLOWUPS.md§1 — a secret leaving through a surface that is not a log line. The rest of that report is covered by the regression below.)jpathover aStreambody consumed the stream: a second expression on the same exchange parsed nothing. A seekable stream is read from its position and restored; a non-seekable one is consumed.RemovePropertieswith a mask that matched them removed the exchange's own__redb_scope:*DI-scope properties, soReleaseScopesfound nothing to dispose. Those keys are never removed.InterceptFrom(" ")is refused at declaration with a message naming the verb; before, the blank mask failed inside route compilation and, withThrowOnCompilationError=false, dropped the route silently. A misplacedInterceptFrom/InterceptSendToEndpointis reported under its own name, not asIntercept().redb.Route.Cache: a hostIMemoryCacheregistered with aSizeLimitthrew on every miss because entries carried noSize(only the package-created cache was sized); every entry is sized now. A hit restores the result where the miss left it: when the inner steps replied inOut(an HTTP enrich), a hit setsOuttoo, so a consumer that answers only onOutanswers on a hit. Duration strings reject a bare number (ttl=5used to mean five days) and non-positive values, at parse time.redb.Route.Templates: the template model no longer exposes__*exchange properties (the live DI scopes under__redb_scope:*), the same ruleredb.Route.JsonTransformalready applied.
Code review of the 4.0 work, second batch.
Marshal("application/gzip" | "application/zip" | "application/base64")on abyte[]body did nothing:MarshalProcessorpassed everybyte[]through as "already marshalled", which is right for an object-model format and wrong for a byte wrapper whose input is bytes.IMessageSerializergainedWrapsBytes(defaultfalse; the three wrappers returntrue), andMarshalonly skips abyte[]body for a format that does not wrap bytes..Marshal("application/json").Marshal("application/gzip")now compresses.- GZip and Zip decompressed without a bound: a small compressed message could expand to gigabytes.
Both formats take
maxDecodedBytes(defaultBinaryWrapperSerializer.DefaultMaxDecodedBytes, 128 MB) and refuse a body that grows past it with anInvalidOperationExceptionnaming the limit; register your own instance to raise it. Zip also skips directory entries and refuses a multi-entry archive instead of silently reading whichever entry came first. AggregationStrategies.GroupedBody()appended to anyList<object?>it found in the accumulator. Bodies are cloned by reference, so an incoming list body was shared by the caller and every branch clone: the strategy mutated the caller's list and, when a branch kept it as its body, added the list to itself. The strategy now appends only to a list it created (a private subtype ofList<object?>).- The compiled-template cache of
redb.Route.Templatesand the specification cache ofredb.Route.JsonTransformare process-wide and were keyed by the relative file name: two contexts with differentBaseDirectoryshared one template, and an edited file was not picked up until the process restarted.TextSource.ResolveCacheKey(baseDirectory)keys a file by its resolved path, size and last write time, so a different directory is a different template and an edited file compiles afresh on the next route build.
Code review of the 4.0 work, third batch.
redb.Route.Templatesescaped a value every time Scriban turned it into a string, not once when it was written:{{ headers.name + '!' }}withO"BrienproducedO\"Brien!with a literal backslash inside the JSON string, andA&Bthrough an operator becameA&amp;Bin XML. The escaping now sits onTemplateContext.Write, the single point where a value reaches the result, so operators, filters,array.joinand object rendering are escaped exactly once; in-template conversions stay culture-invariant. XML output also replaces characters XML 1.0 cannot carry (control characters other than tab / LF / CR, lone surrogates) with U+FFFD instead of producing an unparseable document.- The template sandbox could be left through
expr()and through arguments: both evaluate a route-language expression, and the expression engine invokes any public method it finds by reflection (expr('body.Delete(path)')ran).ExpressionSandbox(public, in the core) marks a flow in which the engine refuses reflection calls withExpressionSandboxViolationExceptionwhile built-in helpers (contains,substring,length, ...) keep working; the template engine opens it around argument evaluation and rendering. Every swallowingcatchon the resolver path lets that exception through, so the render fails loudly instead of yielding an empty value. Multicastdisposed every branch clone after aggregation, including the clone whose body the strategy had just handed to the original exchange:UseLatest()with aStreambody delivered a closed stream,GroupedExchange()a list of exchanges with released scopes. Clones now release their DI scopes only, as Splitter, ScatterGather and RecipientList always did.redb.Route.Cachestored and returned the body by reference. AStreambody is now buffered on the miss (the live message continues with the same bytes) so a hit replays it; aJsonNodebody is copied into and out of the cache, so neither a later step on the miss nor a hit's mutation reaches the cached copy. Text and byte arrays are treated as immutable; any other object is still shared by every hit by design — do not mutate what a cache scope handed you, or clone it first.
Code review of the 4.0 work, fourth batch (core).
- Several intercepts ran in reverse declaration order, and a route's intercepts before the builder's. Now the first declared (builder-level ones first) is the outermost and runs first.
InterceptSendToEndpointmatched a staticToagainst its unresolved URI:To("{{orders.endpoint}}")never matchedkafka://*, so a dry run sent for real. The mask sees the resolved URI, the one the endpoint is created from; the publishedredb.toEndpointis the resolved one too.- A dynamic target (
ToD) was evaluated twice per message, once by the interception and once by the send; with a non-pure expression the published URI and the actual target could differ. The wrapper and the node share the node's resolver and the URI is evaluated once (parked on the exchange for the send that follows, cleared afterwards). UriMaskstripped the query string from both sides, sokafka://orders?acks=allmatched?acks=noneand a prefixkafka://orders?acks=*matched any query. A mask without a query ignores the candidate's query, as before; a mask that carries one matches only that query. Regular expressions are compiled once per pattern, case-insensitive and culture-invariant.UriMask.Validatereports a blank mask or an invalidregex:where the mask is declared (Intercept*,RemoveHeaders,RemoveProperties) instead of failing inside route compilation and, withThrowOnCompilationError=false, dropping the route.- The lifecycle-listener list was a plain
Listenumerated on every exchange: registering a listener while exchanges flow (NotifyBuilderon a running context, a listener adding another from a callback) threw "Collection was modified" out of the notification and failed an unrelated in-flight message. Registration now replaces an immutable snapshot. - After an
AdviceRoute/AdviceAllRoutes, a builder added later was never configured and its routes vanished without a word (DefinitionsPrebuiltshort-circuited every builder). Builders remember whether they have been built; an unbuilt one is built atStart()regardless. - Intercept and
OnCompletionbodies were invisible to the tree walk: the route validator did not validate them andMockEndpoints/Weave*did not reach aToinside them (aTo("kafka://audit")in anOnCompletionhit the real broker in a test).RouteDefinitionexposes them asIBranchingDefinition.Branches. OnCompletionbodies compiled outside the route's compile frame: aReplayablecheckpoint inside one failedStart()with "RegisterCheckpoint called outside route compilation", and the bodies got no message history. They compile inside the frame now.DynamicEndpointResolvercaptured the first caller'sCancellationTokeninto the cached producer start; when that first message was cancelled every later message for the URI awaited a cancelled task forever. A cancelled start is dropped from the cache and the next message retries.ProcessorDefinition.StepIdis no longer settable from outside the framework (a renamed step no longer matched its compiled processor).
Code review of the 4.0 work, fourth batch (packages).
redb.Route.Cache: a miss is computed once per key at a time; concurrent exchanges for the same key wait for that result instead of each running the inner steps (a stampede on a hot key the moment it expires). Region and key are joined with a length-prefixed separator, so regionawith keyb:cand regiona:bwith keycare two entries and clearing a region never reaches a neighbour. A body type name read from a distributed cache is resolved against loaded assemblies only (never loads one from disk), and an entry whose type cannot be resolved counts as a miss instead of arriving as raw bytes. The list of keys written to a distributed cache forgets expired keys. A non-positive TTL or sliding window given in the DSL fails at route build; a key evaluated from a number or a date is written culture-invariant. Thecache:producer's lazy start is atomic (two first messages could store an entry before the TTL was read).redb.Route.JsonTransform: aStreambody is read from its position and left there with the stream open; a body that is not JSON fails with the specification's name; the result carriesContent-Type: application/jsonin every branch (Nodeoutput and an undefined result included).redb.Route.DataFormats.Csv: with a header record configured,List<string[]>no longer receives the header line as its first row; the header written for dictionary rows is the union of every row's keys in first-seen order, not the first row's keys.
Code review of the 4.0 work, fourth batch (core and HTTP, continued).
- The keyed throttle scanned every key's gate after every message (taking each gate's lock), so
throughput fell with the number of live keys; eviction now runs at most once per period. A gate could
also be evicted and disposed between a message's lookup and its use (
ObjectDisposedExceptionon a key idle for two periods); eviction and entry now decide under the gate's own lock, and a retired gate is never entered.ThrottleProcessorno longer touches its disposed token source when disposed under a message in flight. AggregationStrategies.Sum / Max / Mintook any numeric accumulator body for the running total, so over exchanges whose bodies happened to be numbersSum("header.amount")summed the bodies. The running total is marked on the accumulator; a seeded first exchange goes through the expression.Unmarshalof a positionedMemoryStreamhanded the whole buffer to the format, bytes before the position included; the shortcut applies only to a stream at its start.jpath/${jpath(...)}parsed the path text on every message; the parsed expression is cached by path (the body is still parsed per evaluation).- REST DSL: two types with one short name (
V1.Order,V2.Order) in one declaration shared one OpenAPI schema; components are named by short name, then namespace-qualified, then numbered (x-clr-typerecords the origin). Two inlineRoute()verbs whose ids sanitize alike (/{id}and/id) shared onedirect://endpoint and the last handler silently won; the endpoint name carries a fingerprint of the route id. A client header namedquery.<x>arrived as if it were a query parameter; plainquery.*headers are cleared before the real parameters are written. redb.Route.Http.Hosting: the shared host's compiled route table was rebuilt only when the number of routes changed, so an unregister + register pair (a route restart, a REST redeploy) kept dispatching to the removed handler; every change invalidates the table.DataFormatRegistryis thread-safe for registration while routes resolve serializers (a hot-loaded module adding a format).
- The keyed throttle scanned every key's gate after every message (taking each gate's lock), so
throughput fell with the number of live keys; eviction now runs at most once per period. A gate could
also be evicted and disposed between a message's lookup and its use (
A SOAP route can finally choose its fault code (
redb.Route.Soap).SoapFaultExceptioncarries aFaultCodeandSoapEnvelope.BuildFaultaccepts one, but the consumer passed neither: every failure went out assoap:Server, and the reason text was the composed"SOAP fault: <code> - <reason>"rendering rather than the reason the route gave. A route could say what went wrong and not what kind of wrong it was, which leaves a caller only prose to branch on.The server half of that wiring simply had not been written: across the whole tree the exception was thrown only by
SoapProducer, on the client side, when parsing an incoming fault. Both new branches are guarded (chosen?.FaultCode,chosen?.FaultString ?? Message), so for every other exception the response is byte-for-byte what it was.This matters wherever a client branches on the code: WS-Trust, for one, treats
wst:FailedAuthenticationandwst:InvalidRequestas different outcomes.A comparison against a member behind a header or property now resolves in the expression engine (
redb.Route).header.user.Age > 18,header.list.Count > 3,property.cfg.limit > 3andheader.s.Length > 3evaluated to false in every condition and template: the identifier resolver of the AST engine did a flat dictionary lookup of the whole dotted tail (getHeader("user.Age")), while the bare-value path had long resolved the same names through the smart resolvers (literal name first, then the member path). The AST engine now uses the same resolvers, so a member access means the same thing wherever it is written.Filter(Expr("header.amount>1000"))no longer drops every message (redb.Route). The string overload had been fixed earlier; the expression overload still read a string-born expression as a value and coerced it, so the unspaced comparison became a lookup of a header namedamount>1000. An expression born from a string is now compiled as a condition inFilter(IExpression)andWhen(IExpression): whitespace-insensitive, and malformed text fails while the route is built. Typed expressions (HeaderExpression, ...) keep the plain truthiness reading.${...}insideSetBodyExpression/SetHeaderExpression/SetPropertyExpressionunderstands unspaced comparisons and dashed header names (redb.Route)."${header.a>5}"resolved to null and"${header.Content-Type}"to null on that path, while the same placeholders worked throughExpr(...); and"${header.a + 1}"worked on that path but rendered empty throughExpr(...). The two engines had complementary holes. Both now go through the one placeholder pipeline (see Changed).Connector builders no longer compile a plain string as an expression (
redb.Route.File,Ftp,Sftp,Kafka,RabbitMQ,MqttNet,Redis,Http). Every string overload of the fluent DSLs —Password,Host,RoutingKey,FileName,MoveTo,Brokers, ... 66 methods — didnew StringExpression(value)and immediately unwrapped it back to the original text, so the only lasting effect of the round trip was that the string went through the expression compiler. A password such assecret(123—secret(reads as a function call — threwExpressionCompilationExceptionwhile the route was being built. The string overloads now store the string; theIExpressionoverloads are unchanged, and a${...}value keeps being resolved per message by the endpoint options exactly as before. A string is a string, whatever it contains.An explicit expression reads its operator whatever the spacing (
redb.Route).SetHeader("big", Expr("header.amount>1000"))used to yield null — the value dialect only saw an operator surrounded by single spaces, the last place in the language where whitespace carried meaning. Every position now agrees:header.a>10,header.a > 10and a tab- or newline-separated form are one expression in a condition, in a placeholder and in an explicit expression alike. Two consequences follow from "an expression is an expression":Expr("a>b")is a comparison (false), no longer the literal text; and an explicit expression that carries an operator but does not parse (Expr("<xml>")) fails while the route is built instead of yielding null on every message. A plain string is untouched —SetHeader("k", "a>b")storesa>b— and so is every connector option.A decimal literal inside a function argument parses on every machine (
redb.Route).max(2.5, 1),round(x, 2)with a decimal,abs(-2.5)compiled on an en-US machine and threwExpressionCompilationException("The input string '2.5' was not in a correct format") on a ru-RU one: the parser read numeric literals with the ambient culture. Source text is culture-invariant now — the meaning of a route cannot depend on the locale of the box it runs on. Pinned under en-US, ru-RU and de-DE.ThrottleExpressionthrottles per message (redb.Route). The template was evaluated once at route build against an empty exchange, and when that failed (a${header.rate}has no header to read at build time) the limit silently becameint.MaxValue: the throttle was a no-op that looked configured. The limit is now computed on every message, so it can come from a header, a property or an expression and change between messages, as the Apache Camel throttler does with a dynamic expression.Throttle(Func<IExchange, int>)is a member ofIRouteDefinition;ThrottleExpressiongained anIExpressionoverload. A malformed template fails atStart(); a limit that evaluates to a non-positive number fails that exchange withInvalidOperationExceptionrather than letting it through.ThrottleProcessorreplaces its fixed-capacity semaphore with a sliding-window gate that serves waiters in arrival order; the fixed-limit form andRejectOnOverflowbehave exactly as before.A public field reads like a property through every root (
redb.Route).header.x.Fieldandbody.Fieldreturned null whileproperty.x.Fieldreturned the value: only one of the three member-resolution paths fell back to fields. Both now do, soheader.,property.andbody.resolve an object identically — properties, fields, nested objects, collections, indexers, dictionaries — in the value, condition and placeholder positions alike.minandmaxwork over a collection (redb.Route).min(property.nums)andmax(header.list)returned null whilesumandavgover the same collection worked: the two were two-scalar-only. A single collection argument now folds over its numeric items; the two-scalar form is unchanged. Both evaluation modes share one implementation.contentTyperesolves in the expression engine (redb.Route). The removed hand-written branch knew the accessor; the unified engine now does too, soFilter("contentType == 'application/json'")works.A string condition without spaces around its operator no longer discards every message (
redb.Route)..Filter("header.amount>1000")compiled to a lookup for a header literally namedamount>1000. That header never exists, so the filter evaluated to false for every message — no exception, nothing in the log, all traffic silently dropped. The spaced formheader.amount > 1000worked, which made the failure look like a data problem rather than a parse problem. The same applied toWhen.A condition is now compiled as a boolean expression, so whitespace between tokens carries no meaning:
header.a>10,header.a > 10,header.a > 10and tab- or newline-separated forms are one and the same condition. An operator inside a quoted literal stays part of the literal, soheader.name == 'a > b'compares against the texta > b.Values are unaffected.
SetBody,SetHeader,Transformand every connector option string keep their existing routing, so a literal stays a literal — prose such asblack and white, base64 padding such asdGVzdC1zZWNyZXQ==, a password containing>or an XML fragment are all untouched. That is deliberate: a condition and a value are different positions in the language.A comparison inside
${...}is now evaluated instead of rendering as an empty string (redb.Route)."${header.amount > 1000}"produced"", and so did every comparison and word-logic form, because the placeholder was matched against the accessor branches first: a placeholder starting withheader.was taken for a header whose name isamount > 1000. Such a header never exists, so the placeholder silently rendered as empty. Curiously, the same expression in brackets —"${(header.amount > 1000)}"— always worked, which is how the evaluator's presence was hidden.Comparisons and word logic now render their result:
"${header.a > 10}"and"${header.a>10}"both giveTrue. The template position therefore agrees with the condition position on every form of the test corpus.Arithmetic inside a placeholder followed once the template engine was unified (see the "one placeholder pipeline" entry under Changed):
"${header.a + header.b}"renders45, and"${header.Content-Type}"keeps resolving the header, because the exact name is tried before the text is read as an expression.A condition that cannot be compiled now fails while the route is being built. Previously a malformed condition compiled to something meaningless and turned into a constant answer on live traffic.
.Filter("header.amount >")and the like now throwExpressionCompilationExceptionfromRouteContext.Start(), before a single message flows. An empty or whitespace-only condition throwsArgumentException.Whenreached through the route-level alias now records its source.When(this IRouteDefinition, string)set neitherSourcePredicatenorSourceExpression; the nestedFilter(string, configure)overload lostSourceTemplatethe same way. Both now record what they were given.A SOAP consumer could not actually serve TLS (
redb.Route.Soap). The consumer registered its listener with the TLS flag but never passed a certificate, and the fluentSoap.Listen(...)builder always emitted thesoap:scheme, so there was no way to ask for TLS through the DSL at all. What reached Kestrel was "serve HTTPS, no certificate": on a developer's machine that quietly picks up the ASP.NET development certificate and looks like it works, and on a server it fails with an error that names neither the route nor TLS. Client certificates were not reachable either, so an mTLS-pinned SOAP endpoint could not be configured.The consumer now carries the same TLS surface the gRPC consumer has:
sslCertPath,sslCertPassword,clientCertificateModeandallowedClientThumbprints.SoapConnectionFactorycarries them too, so the certificate password lives in the registry rather than in the endpoint URI — the URI is the route key and travels through logs, telemetry and the dashboard as an ordinary string. A URI value wins where it says something, the factory fills the rest.Soap.Listen(...).Ssl(certPath)now emitssoaps:, and.ClientCertificate(mode, thumbprints)sets the mTLS policy.Two configurations that cannot work are now refused when the consumer starts, rather than at the first handshake: TLS without a certificate, and a client-certificate policy on a plaintext listener. The second is the more dangerous of the two — it would leave an operator believing the endpoint is pinned to their partners while it is open to anyone.
Existing routes are unaffected: a
soap:consumer with no TLS options behaves exactly as before.A SOAP route could not learn who was calling (
redb.Route.Soap). The consumer surfaced nothing from the connection: no caller address, no client certificate, nothing about the request beyond the envelope. Every IP-keyed protection a route might apply — rate limiting, brute-force lockout, auditing where a request came from — had nothing to key on. They did not fail; they saw no address and did nothing, which reads in a log as «no abuse» rather than as «not wired».The caller's address and source port now arrive as
redbSoap.remoteAddress/redbSoap.remotePort, taken from the connection rather than from anything the caller sent, and a TLS client certificate arrives asredbSoap.clientCert*.emitHttpCompatHeaders=true(.HttpCompatHeaders()in the DSL) additionally mirrors the address, path and method intoredbHttp.*, so processors written against the HTTP transport work unchanged behind a SOAP endpoint. That bridge is off by default and its shape matches the gRPC consumer's: the native headers always carry the facts, and writing into another transport's namespace stays a deliberate act.A prefixed fault code was written without declaring its prefix (
redb.Route.Soap). A fault code is a QName in both SOAP versions, sowst:FailedAuthenticationmeans nothing unlesswstis bound on the envelope — and it was not. The result reads correctly to the eye and does not resolve in a client that treats it as the QName the specification says it is, which is precisely the kind of client that branches on fault codes rather than on prose.The prefix is now declared. The standard ones (
wst,wsse,wsu,wsa,wsrm) are known, andSoapFaultExceptiontakes an optional namespace for a code in a namespace of the caller's own. A prefix that is neither known nor supplied is still written as it was handed over: inventing a namespace for it would be worse than leaving it unresolved.Envelopes with the default code are unchanged —
soapis already bound, and nothing is added for it.
Added
IMessageValidator.ValidateAsync(redb.Route). A default interface method that forwards toValidate;ValidateProcessorawaits it, so a validator that has to consult a store or a service can override it and no longer blocks.PredicateValidatorholds anIPredicate(delegate constructors kept) and awaitsMatchesAsync—Validate(predicate)now behaves likeFilter,WhenandLoopfor an asynchronous predicate.Filter(IPredicate),Loop(IPredicate, ...),Validate(IPredicate, ...)are members ofIRouteDefinition;When(IPredicate)is a member ofChoiceDefinitionandWhenDefinition(redb.Route). They used to be extension methods that unwrapped the predicate into its synchronousMatchesat the boundary. The predicate is now stored as the condition of the scope —FilterDefinition,WhenDefinition,LoopDefinitionhold anIPredicate, a plain delegate is wrapped in aLambdaPredicate— and the processors (FilterProcessor,ChoiceProcessor,LoopProcessor) awaitIPredicate.MatchesAsync. Until now nothing in the engine called the asynchronous half of the contract: a predicate that consults a store or a service ran synchronously whatever it declared.SourcePredicateis the very instance that executes, not a copy kept beside it. The processors keep their delegate constructors andWhenClause.Predicatekeeps its delegate shape, so existing code compiles;WhenClause.Conditionexposes the stored predicate..LoopWhile(condition)— a loop driven by a condition (redb.Route).LoopExpressionreads its string as an iteration count; there was no string form ofLoop(Func<IExchange,bool>)at all, so a "repeat while this holds" loop could not be written from a string. Two forms, with and without a nested configurator. The name is deliberately notLoopExpression: the two mean different things and must not be confused..Validate(condition)— validation from a condition string (redb.Route).Validatepreviously took a delegate or anIMessageValidator. The string overload uses the same condition compilation asFilterandWhen, and honourserrorMessageandthrowOnFailure.ExpressionResolver.IsTemplate(string). Answers whether a string is a${...}template rather than a bare expression. The same regular expression was duplicated insideStringExpression.IConditionSource— one contract for what a condition scope was built from (redb.Route).FilterDefinitionandWhenDefinitionimplement it, so an introspecting consumer matches on a single type instead of enumerating definition classes and hoping the property names line up. Three names, three fixed types, read-only:SourcePredicate(IPredicate?),SourceExpression(IExpression?),SourceTemplate(string?). A condition written as a delegate leaves all three null — a lambda has no source to capture.Choice().When(expression)now records the expression it was given. TheIExpressionwas folded into a delegate and lost;WhenDefinition.SourceExpressionnow holds it, matching whatFilter(expression)has always done..Log()— the rich-log scope now opens without a level argument. The fluent scope (.Log(level).Message().Header().Property().ShowRouteId().EndLog()) previously required an explicitLogLevel; the parameterless overload opens it atInformation, so the canonical form reads.Log().Message("…").Header("correlationId").Property("traceId").EndLog()..Log(level)is unchanged.Incoming attachments are reachable (
redb.Route.Telegram).TelegramUpdateMapperput onlymsg.Textinto the body and nothing at all from attachments. A voice note therefore arrived with an empty body andtelegram.messageType = Voice: a route could see that a file had come and had no way to fetch it, because thefile_idnever reached the exchange. New consumer headers, present only when the message carries a file:telegram.attachment.kind/.fileId/.mimeType/.fileSize/.duration/.fileName/.caption. Covers voice, audio, video note, video, animation, document, sticker and photo — for photos the largest size of the ladder is reported, because downscaling is the caller's choice and upscaling is not available. Deliberately not the existingtelegram.fileId: that one is a producer instruction ("send this already-hosted file"), and headers travel with the exchange. A route that consumed a voice note and answered with a document would have echoed the user's own recording back at them, silently, since both sides read one key. The body is untouched, and a caption stays a header: promoting it would make a captioned photo indistinguishable from a typed command downstream. Long polling and webhooks share the mapper, so both paths gained the headers at once.
Tests
Nothing that leaves the process carries a secret — proved end to end (
redb.Route.Tests).docs/SECURITY_URI_REDACTION_PLAN.mdasked for this regression and BR-3 (redb.Tsak/docs/BOUNDARIES_AND_FOLLOWUPS.md§1) is the report behind it. A route whose consumer URI carries secrets in every shape — apassword=query parameter, the connector-specificbindPasswordandsessionTokenfrom the leak audit, and a userinfo password — is compiled, started, sent a message and stopped, while log lines, span tags, metric labels, theCompiledRouteDTO the dashboard renders and the health-check payload are captured. No surface may contain any of them, and the route must still be recognisable (a blanket "log nothing" would pass a leak test and be useless in an incident). Each secret is unique per run, so the assertions cannot be spoiled — or falsely satisfied — by what other tests do in parallel. Verified to fail: dropping the sanitizer from a single site (the DTO) turns it red with all four secrets in cleartext. The core sites themselves were already redacted before this session; the regression is what makes that a guarantee rather than an observation, and it is what closes the redb.Route side of BR-3.The telemetry tests no longer collect other tests' spans (
redb.Route.Tests,redb.Route.Tests.Http). AnActivityListeneris process state: filtering by source name alone — the obvious spelling — made a test see the spans of every route any other test was running at that moment, soTracedDslTestsandHttpTelemetrySmokeTestsflaked under a full parallel run and were always green in isolation (six runs of the untouched tree produced two failures). Serialising by collection cannot help; the listener is not per collection.RouteTelemetryProbekeeps the source filter and adds the real discriminator: a predicate over the finished span, normally itsredb.route.idtag, orredb.route.endpointfor a transport span with no route behind it. Both tags exist for production observability, so no test-only hook was added to the framework. Collection is also thread-safe now;ActivityStoppedarrives on whatever thread finished the span and the spans were being appended to a plainList. Processor metrics keep their limitation, now stated where it bites: the counters carry no route tag, so a test may assert a lower bound and never "unchanged" (docs/Route-XML/BOUNDARIES.md§2.11). The same rule caughtClearAllCaches_EmptiesAll, which asserted an empty expression cache while other collections compile templates continuously; it now proves the clear per entry, by delegate identity of a template text unique to the test, which no parallel run can put back.Characterisation net over the expression language (
redb.Route.Tests). One corpus of ~220 forms — literals, accessors, member access including public fields, comparisons in every spacing, word logic, parentheses, arithmetic, prefix and postfix increments, functions,min/max, decimal literals, ternary, index access, whole-string placeholders, Apache Camel idioms, malformed input, and the configuration-shaped strings that must stay literal — is evaluated in every position a string can occupy (value, condition, template; the hand-written logical branch was a fourth column until its removal) and compared against a recorded snapshot. A failure names every line whose outcome moved, so a change to expression handling can no longer rearrange one position while quietly breaking another. The snapshot is generated under the invariant culture so it is the same on every machine.Putting the net in place immediately pinned several behaviours nothing had recorded before, among them that template interpolation formats numbers with the ambient culture — the same route emits
2.5or2,5depending on where it runs. All of them are documented indocs/Route-XML/BOUNDARIES.md.
Changed
One truthiness rule instead of three (
redb.Route). A value became a boolean by three different rules depending on where it was read: the DSL boundary (Filter("${header.x}")), the word-logic operators andlogical()inside the engine, and the hand-written logical branch — and they disagreed on0,"0"and"no".Filter("${header.zero}")passed a message whileFilter("logical(header.zero)")dropped it.RouteTruthinessis now the single rule and every conversion site delegates to it: a bool is itself; a number is non-zero; a string is a boolean word (true/false,1/0,yes/no,y/n,on/off) and otherwise non-empty; null is false; any other object is true.Behaviour changes to note: a zero header or property is now false in a condition (it used to be true, being a non-null object); the strings
"0","no","off"are false (they used to be true, being non-empty). Equality is deliberately not truthiness:header.a == 'x'witha = 42stays false — only bools, numbers and explicit boolean words take part in equality coercion.The boolean word set is English-only by design; a routing language must not change meaning with the author's locale. The Russian words the old
logical()parser accepted now read as ordinary non-empty strings.One placeholder pipeline (
redb.Route).${...}used to be interpreted by two engines with complementary holes — the one underExpr(...)/StringExpression/ connector option strings, and the one underSetBodyExpression/SetHeaderExpression/SetPropertyExpression. Both now route through the same compiler, and the rules are:jpath(...)/xpath(...)keep their own syntax;bodyandcontentTypeare direct accessors.- A
header.orproperty.placeholder resolves literal name first: the exact name when it exists (${header.Content-Type}), then the member path (${header.user.Age}), then the whole text as an expression (${header.a+1}is11). The name check happens per message, which is what makes a dash usable in a name at all. - Anything else is a full expression: arithmetic, comparisons, functions, ternary, index access, whitespace-insensitive.
- A whole-string placeholder keeps the CLR type of its value.
Expr("${header.a}")with an int header now yields the int, not"10";Expr("${header.a > 5}")yields a bool.Evaluate<string>still converts, so code asking for a string is unaffected. This was already the behaviour ofSetHeaderExpression; the two paths now agree. Expr("${header.missing}").Evaluate<string>()isnullrather than""— the typed reading of a missing value. Mixed templates still render a missing value as empty text.- A placeholder that cannot be compiled (
${<xml>},${dGVzdC1zZWNyZXQ==}) fails while the route is built. It used to be echoed as its own text, which could only ever mislead. - A placeholder that is a bare word (
${myVar}) is a property lookup, as before.
ResolveTypedOrTemplatedelegates to the same pipeline; the privateResolveExpressionladder is no longer on any DSL path and remains only forredb.Route.Llm.WhenDefinition.SourceExpressionis renamed toSourceTemplate, andSourceExpressionnow holds anIExpression?. It used to be astring?— soSourceExpressionmeant anIExpressiononFilterDefinitionandSplitDefinitionbut a string onWhenDefinition, and reading code was bound to get it wrong. The three names now carry one fixed type each everywhere, enforced byIConditionSource. Breaking only for code that reads the property: the setters have always beeninternal, so no route ever wrote it, and the route-authoring DSL is untouched.FilterDefinition.SourceExpressionis no longer set by the string overload ofFilter. A condition written as a string is carried bySourceTemplate, and the predicate built from it bySourcePredicate;SourceExpressionnow means what its type says — the filter was built from anIExpressioninstance.The OpenAI provider family now uses the typed failures (
redb.Route.Llm).LlmRateLimitException(429, carryingRetry-After) andLlmTransientException(5xx and the soft 529 "overloaded") existed andAnthropicProviderthrew them, butOpenAiProvider(two call sites) andOpenAiEmbeddingProviderthrew a bareHttpRequestExceptionwith the status as text. A caller could not tell "wait and retry" from "your key is wrong", and retrieval degrades to keyword search on any embedder failure, so an expired credential looked exactly like a busy server. The mapping moved intoLlmHttpErrors.FromResponseand is now shared by all four call sites, Anthropic included: two copies of "which status means retry" drift apart, and the drift only shows in production. Anthropic's behaviour is unchanged.4xxother than 429 stays a plainHttpRequestExceptionon purpose: a wrong key is not worth retrying. ⚠️ Behavioural break for a caller that catchesHttpRequestExceptionaround an OpenAI-compatible provider. 429 and 5xx now arrive asLlmRateLimitException/LlmTransientException, and those derive fromException, not fromHttpRequestException.
Removed
The hand-written logical branch — one language, one parser (
redb.Route). The library carried a second expression compiler next to the AST engine:LogicalPredicate,LogicalExpression,ExpressionResolver.EvaluateLogicalExpression,GetCompiledLogicalExpression,CompileLogicalPredicate,ClearLogicalExpressionCache, the logical-expression cache and itsCacheStatisticscounters (LogicalExpressionCount,-Hits,-Misses,-HitRate). It parsed comparisons by hand and got parentheses wrong:(header.a > 10) AND (header.b < 5)answered false, silently. Nothing in the library called it — conditions had already moved to the AST engine — andLogicalExpressionwas never constructed anywhere. All of it is gone; roughly 800 lines.${logical(...)}is unaffected as a language function: the AST engine has its own implementation, which the removed branch had been shadowing inside templates. Three forms that the branch answered wrongly now answer correctly:${logical((header.a > 10) AND (header.b < 5))},${logical(header.a + header.b > 40)}and${logical(header.s == 'x AND y')}all renderTrue.Migration for the removed public API: a condition string is evaluated through the DSL (
Filter,When,LoopWhile,Validate); a boolean-valued expression is written as a condition string or via the predicate factories onIExpression(isGreaterThan,contains, ...). The tests that exercised the branch migrated to the condition path without losing a single assertion of behaviour.The old template compiler (
redb.Route). Superseded by the one placeholder pipeline above; about 470 lines of expression-tree builders that only the old engine used.LogTemplateDefinitionandRichLogDefinition(redb.Route). Both were unreachable — nothing in src, tests or demos constructed them and no code matched on their type — and both duplicated a live path that produces the identical processor:.Log("…${header.x}…")already routes through the template processor viaLogStaticDefinition, and the fluent.Log(level).Message().Header().Property().EndLog()scope (RichLogScopeDefinition) produces the sameRichLogProcessoras the flatRichLogDefinition. No logging behaviour changes; use the DSL forms.
3.7.2
No changes of its own. The version moves with the ecosystem so the exact-version pins between packages keep resolving: redb.Route.Core 3.7.2 depends on redb.Core 3.7.2, and a partial release would demand a package that does not exist.
3.7.1
Why 3.7.1, and what happened to 3.7.0. 3.7.0 is withdrawn: it was built on .NET 9 and carries known vulnerabilities in its dependencies (see Security below). Every 3.7.0 package is unlisted on nuget.org and the
v3.7.0releases 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.A patch, not a minor: the public API surface does not change. Adding
net10.0to the target list breaks nothing for existing consumers, andnet8.0/net9.0are kept.
Changed — the build moved to .NET 10
The applications and artifacts were built on net9 while the 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.
The redb.Tsak.* and redb.Identity.* libraries now declare net8.0;net9.0;net10.0 — exactly like
the core and Route, so the whole ecosystem is uniform. Host applications and tests are pinned to a
single net10.0. Images, archives and tags are -net10.
.NET 8 and .NET 9 both reach end of support on 10 November 2026 — the same day, Microsoft aligned
the STS 9 date with LTS 8. .NET 10 is supported until 14 November 2028. net8.0 and net9.0
remain in the libraries' target list for now.
Security — six high-severity advisories
Found while moving to .NET 10: changing the TFM forced a from-scratch rebuild and the NuGet audit spoke up. It stays silent on an incremental build, which is how all of this reached 3.7.0.
redb.Route.Sftp—SSH.NET2025.1.0 (GHSA-q939-rpr3-3284, high), declared directly. Bumped to 2026.0.0; the connector's own suite passes 207/207 against it.- The Kafka, RabbitMQ and Redis test projects pulled the same vulnerable
SSH.NET2024.2.0 transitively. The root wasTestcontainers.*4.3.0 against 4.14.0 available — the version was raised rather than the symptom patched in three places.
3.7.0
Why a minor. A new connector package (
redb.Route.Soap), two new EIPs in the DSL (.ClaimCheck(...),.ControlBus(...)+ thecontrolbus:component) and a rebuiltredb.Route.Grpcare all new public surface, so this cannot be a patch. The ecosystem moves together —redbcore,redb.Tsakandredb.Identityship 3.7.0 alongside.Two behavioural changes need reading before upgrading, both in
redb.Route.Grpc: a failed call now throws (ThrowOnError, defaulttrue) where it used to return an error document, and errors reach clients as real gRPC statuses instead ofOKplus a body. See Changed (behavioural).InMemoryFileIdempotentRepository/InMemorySftpIdempotentRepositoryare removed — see Removed.
Added
redb.Route.Grpc— a gRPC method address is now a route, and many of them share one port. The consumer registers its method address (/package.Service/Method) as a path route on the shared Kestrel host — the sameSharedHttpServerManagerthat already serves Http, As2 and Soap — and speaks the gRPC wire protocol itself (GrpcWire: length-prefixed framing,grpc-status/grpc-messagetrailers,grpc-timeoutdeadlines). Previously everyFrom("grpc:host:port")built its own Kestrel, so a second gRPC route on the same port failed to bind and a facade had to be one route with aChoice()inside. NowFrom("grpc:0.0.0.0:5001/identity.v1.Identity/Token")and…/Introspectare ordinary routes with their own ids, policies, metrics and lifecycle, and the address — not a private header — selects them. A URI with no method address keeps serving the built-in genericRedbService/ProcessandProcessStream, unchanged.redb.Route.Grpc— real gRPC statuses out of a route. The consumer maps the transport-neutralstatus.codethat every controller dispatcher writes onto a gRPC status (401 →Unauthenticated, 403 →PermissionDenied, 404 →NotFound, 429 →ResourceExhausted, …), and honours an explicitredbGrpc.StatusCode/redbGrpc.StatusDetail.redbGrpc.Trailer.*headers become response trailers. A non-OK status is delivered trailers-only because clients discard the payload of a failed call.redb.Route.Grpc— typed.protoservices without generated server stubs.Envelope=Autokeeps theRedbMessagewrapper for the built-in address and passes raw protobuf bytes for any other, so a client generated from a real.protocan call a redb route directly..Envelope(Message|Raw)overrides.redb.Route.Grpc— server streaming, mTLS, health. AnIAsyncEnumerablereply body is written one frame per yield (the framework's own streaming shape, as in the HTTP consumer);.ClientCertificates(mode, thumbprints…)requires and pins client certificates, surfacingredbGrpc.ClientCert*;.Health()servesgrpc.health.v1.Health/Checkfor Kubernetes / Consul / Envoy probes. The client address is resolved toredbGrpc.RemoteIp/RemotePort, and.EmitHttpCompatHeaders()mirrors it intoredbHttp.RemoteAddressso IP-keyed processors written for HTTP (rate limiting, lockout, device metadata) work behind a gRPC facade unchanged.redb.Route.Grpc— gzip. Compressed requests are accepted and inflated (the size limit is re-checked after inflation, so a small frame cannot expand into an arbitrarily large buffer);identity,gzipis advertised back on every response..Compression(GrpcCompression.Gzip)compresses replies, but only when the caller advertised gzip, and gzips outgoing requests on the client side. An unknown codec is answered withUnimplementedinstead of a parse failure.redb.Route.Grpc— the producer streams too..Streaming()on a client endpoint makes the call server-streaming and puts anIAsyncEnumerableintoOut.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'sproducerStrategy=STREAMING.redb.Route.Grpc— a stream body on a unary address fails loudly. A unary call carries exactly one message, so anIAsyncEnumerablereply there cannot be delivered; the route now gets an error naming the address instead of the enumerable's type name going out as the payload.redb.Route.Grpc— live interop tests against an independent gRPC stack. Since the wire protocol is ours now, correctness is verified against Node.js@grpc/grpc-jsin a container (C:\Work\yaml\grpc), both directions and cross-process: a foreign server parses our frames, a foreign client accepts our replies, trailers,PERMISSION_DENIEDstatus, server stream, 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. Gated on the container (--filter Category=Interop), mirroring the SOAP and AS2 fixtures.redb.Route.Grpc— camel-grpc parity on the URI.grpc://host:port/my.Service?method=Callworks alongside the full-address spelling, plusmaxMessageSizeandnegotiationType=PLAINTEXT|TLS.redb.Route.Controllers—[GrpcMethod("Name")]. Pins the name gRPC callers dispatch on so renaming a C# method is not a breaking change, mirroring[SoapOperation]for SOAP.redb.Route.Http.Hosting— client certificates on the shared host.RegisterRouteacceptsclientCertificateModeand aclientCertificateValidationcallback (thumbprint pinning on top of Kestrel's chain validation), available to every HTTP-based transport.
Added
.ClaimCheck(...)— the Claim Check EIP is now reachable from the DSL. The processor, the five operations, the headers and two repositories (in-memory and file-backed) were all implemented, but nothing constructedClaimCheckDefinition, so the pattern could not be used from a route at all. A large body is now checked into a store and a short claim key travels the route in its place:.ClaimCheck(ClaimCheckOperation.Set, "order-42") // body → key, original type kept in headers .To("kafka://orders") // the broker carries the key, not the payload .ClaimCheck(ClaimCheckOperation.Get, "order-42") // key → body, restored as its original typePush/Popuse 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: an explicit instance, a name registered withcontext.AddClaimCheckRepository(name, repository), a context default set withSetDefaultClaimCheckRepository, anIClaimCheckRepositoryservice, or — failing all of those — one shared in-memory repository created per route context, so aSetin one step and aGetin another reach the same store. An unknown repository name fails at startup naming the missing registration, not at the first message.
Removed
InMemoryFileIdempotentRepository(redb.Route.File) andInMemorySftpIdempotentRepository(redb.Route.Sftp). Both were byte-for-byte copies ofInMemoryGenericFileIdempotentRepository, differing only in the signature of their staticDefaultKey, and neither was reachable in production: the file consumer constructs the generic one directly, no endpoint option names a repository, and nothing resolves them from the registry or by reflection. They were left behind when the GenericFile extraction unified the three implementations — FTP, added later, never had one. Their tests were consolidated intoredb.Route.Tests.GenericFileagainst the class that actually runs. Code that constructed either type directly should useInMemoryGenericFileIdempotentRepository, or the coreInMemoryIdempotentRepositoryfor a non-file.IdempotentConsumer(...).
Fixed
redb.Route.File/redb.Route.GenericFile: four defects found by a critical review of the file transports, each reproduced before it was fixed. Three of them lost data silently, and all three survived a fully green suite because the tests covered options one at a time while the defects lived in their combinations.readLock=Renamedelivered empty files forever. The strategy renames the file aside to claim it, but the consumer kept reading the original path. The read failed, the failure was swallowed into an empty body, post-processing failed on the same missing path, the lock renamed the file back, and the next poll started over. Measured before the fix: six deliveries of a one-file directory, every body empty, the file still there. A read-lock strategy can now report the path it moved the file to.readLock=FileLockdid the same, for the opposite reason. The strategy held the file open withFileShare.None, so the consumer's own second open was refused by its own lock, and again the failure became an empty body. The strategy now exposes the handle it holds and the consumer reads through it. It also opens withFileShare.Deleteso post-processing can delete or move the file while the lock is still held — exclusivity against other processes is unchanged and pinned by a test.idempotenttogether with anyreadLockcould lose a file permanently. The idempotent key was claimed before the read lock was taken, and the lock-refused path returned without releasing it. A consumer that lost the lock race had already marked the file as processed; since the default key is path + last-modified + size, it stayed marked forever and no consumer ever picked the file up again, with nothing in the log. The read lock is now taken first.- A custom
idempotentKeywas used as a literal. The DSL accepts an expression and the README promised one, but the runtime took the string as-is, so every file got the same key: the first file was processed and every later one silently skipped, permanently.idempotentKey,moveTo,preMoveandmoveFailednow resolve the same file variables thatdoneFileNamealready did —${file:name}and${file:name.noext}. Exchange-level expressions remain unavailable there by design: these values are needed before the exchange exists or while it is being disposed, and the DSL and README now say so instead of implying otherwise. Affectsredb.Route.Ftpandredb.Route.Sftptoo — the code is shared.
redb.Route.GenericFile: the idempotent repository folded case, soOrder.csvshadowedorder.csv. The key embeds the file path and the set compared withOrdinalIgnoreCase. On every SFTP/FTP server and every non-Windows file system those are two different files, and the second one was skipped as a duplicate it never was — silently, and permanently for as long as the process lived. NowOrdinal, matching the coreInMemoryIdempotentRepository.redb.Route.GenericFile: an unreadable file arrived as a successfully processed empty message.CreateExchangeAsynccaught every read error, logged a warning and substitutedArray.Empty<byte>(), after which post-processing happily deleted or archived the file. This is what made the two read-lock defects above silent. A read failure is now a processing failure for that file: the idempotent key is released,moveFailedapplies, the file stays where it is, and the rest of the poll batch continues.redb.Route.Grpc: four defects found by a critical review of the connector, each reproduced before it was fixed. 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-timeoutcould kill the request at the host. The microsecond arm multiplied a caller-suppliedlongby 10 unchecked;1000000000000000000uwrapped to a negative tick count andCancelAfterthrewArgumentOutOfRangeExceptionbefore the consumer's try block, while9223372036854775807uproduced a tiny negative deadline that cancelled the call instantly. Worse, thecatchonly handledOverflowException, butTimeSpan.FromHoursand friends raiseArgumentOutOfRangeException— so9223372036854775807Hescaped the method too. Both are caught now, and the multiply ischecked, so 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.Addthrows on spaces, non-ASCII, and on any-binsuffix, which is a perfectly legal HTTP header name (trace-bin). That loop runs before the producer's try, so one odd header from an upstream HTTP consumer took the whole call down with anArgumentExceptioncarrying no status. Unrepresentable keys are now dropped and logged; the rest of the headers still travel. - A malformed envelope was reported as our fault. In envelope mode the consumer parses
caller-supplied bytes as a
RedbMessage; garbage fell through the catch-all asINTERNAL— telling the caller "server problem, retry" about input only they can fix — and put the protobuf parser's own wording intogrpc-message. NowINVALID_ARGUMENT, naming what was expected. - A server stream that broke mid-flight was invisible. Streaming failures surface while the consumer
enumerates, long after
Processreturned, so.OnException, retry and dead-letter cannot see them — inherent to lazy streaming. But nothing recorded them either: 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.
- A crafted
redb.Route.Llm— review hardening: scheduled consumer, tool-loop, streaming and tool-error JSON. A provider/HTTP timeout no longer terminates the scheduled consumer forever (anHttpClienttimeout throwsOperationCanceledExceptionon a different token — only real shutdown stops the loop now). HittingMaxIterations/budget mid tool-round returns the last assistant content instead of an empty answer. The provider'sHttpClientis reused (cached in the factory) rather than minted and leaked per call, and a mid-stream failure is recorded as an endpoint error and failure metric instead of vanishing. Tool-error results are JSON-serialized, so a control character in a message no longer produces invalid JSON.redb.Route.Llm— the nativeAnthropicProviderno longer 400s on current-generation Claude models. Anthropic's Messages API changed the sampling contract across generations, and the connector senttemperature/top_punconditionally: any config that set them and targeted Opus 4.7/4.8/5, Sonnet 5 or Fable 5 was rejected with HTTP 400 (those models removed the knobs), and Claude 4.0–4.6 rejected the two together. The provider now resolves anAnthropicModelProfilefrom the model id and shapes the request to the model's sampling policy — Claude 3.x takes both, Claude 4.0–4.6 takes one (temperaturewins,top_pdropped), Claude 4.7+/5 takes neither — with an unrecognised id defaulting to the modern (no-sampling) contract so a future model release never 400s on a removed field. A dropped knob logs a warning and leaves anllm.sampling.droppedevent on the span (model id, tier, dropped params) rather than failing silently.LlmConnectionFactory.ModelContractTier(legacy/transitional/modern) overrides the inference for proxy or self-hosted model ids. Anthropic-only; the OpenAI-compatible providers are untouched.redb.Route.As2— MIC now matches for the compress-without-sign profile. The sender hashed the compressed part while the receiver hashes the decompressed payload, soCompress=true, Sign=falsealways reported a MIC mismatch; the unsigned MIC is now computed over the uncompressed payload.redb.Route.Soap— review hardening across modes, WS-Security symmetry and the controller/MIME edges. Message (transparent-proxy) mode no longer decrypts/verifies the producer's response envelope (it now stays verbatim, matching the consumer's inbound skip); the consumer now signs/encrypts its response when certs are configured, so the producer's decrypt/verify leg is actually symmetric; a non-XML body in Message mode no longer throws out of the header-plane read; SOAP 1.2actionparses correctly when it is not the last Content-Type parameter. The SOAP controller dispatcher now fails fast on an ambiguous operation name across controllers (instead of silently running the first), awaitsValueTask/ValueTask<T>returns, and matches operation names case-sensitively (XML names are). The MTOM multipart parser respects quoted Content-Type parameters, so a foreignboundary/start-infocontaining;no longer breaks parsing.- An unhandled exception in a SEDA/in-memory consumer no longer kills the worker (issue #6).
The worker loop caught only cancellation and channel-closed, so a single failing exchange (e.g. 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.ProcessWithTrackingnow logs an unhandled exchange failure and drops it, so the consumer keeps draining — Apache Camel'sDefaultErrorHandlerSedaConsumerbehaviour.OnException/DeadLetterChannelremain the handled paths (they run inside the pipeline and never reach this net); broker consumers are unaffected (they use the manual ack/nack path). The fix covers everyProcessWithTrackingconsumer:seda,direct-vm,timer, and the S3 / Elasticsearch / Firebase / LDAP pollers.
AddRouteBuilder<T>()/AddComponent<T>()no longer fail host startup (issue #5). The builder/component was registered only under its base type (RouteBuilder/IComponent), while the configurator resolves the concrete type — so startup threwInvalidOperationException: No service for type '…' has been registered. Both are now registered under the concrete type and exposed as the base type (same singleton). The documented onboarding path (AddRedbRoute(r => r.AddRouteBuilder<MyRoutes>())) works.- README —
.Retry(...)examples corrected to the real error-handling API..Retryis not a route step; the README showed it as one in several places (a compile error). Route-level retry isOnException(...)withMaximumRedeliveries/RedeliveryDelay; per-step retry isTransacted().Retry(attempts, delay). redb.Route.Controllers— a JSON-object request body binds to controller parameters by name (gRPC / SignalR).ResolvePositionalused to drop the whole object into the first parameter, so a method with a route/path parameter plus a[FromBody](the object became the id, the body stayed null) or with simple parameters (the object could not become anint) failed. A JSON object now binds each parameter by its name — honouring the[FromRoute]/[FromQuery]key, case-insensitively — while[FromBody](or a lone unbound complex parameter) still receives the whole object, and unmatched parameters keep their defaults. JSON arrays and single values stay positional, so existing callers are unchanged.redb.Route.Grpc— a reply body that is not a payload is refused instead of stringified. A route answering with, say, a dictionary used to put the literal textSystem.Collections.Generic.Dictionary\2[…]on the wire with an OK status. That is reachable in practice: a builder-levelOnException(...).Handled()anywhere in the context replaces the answer with its own error document, and the route ends before any encoder of ours runs. The consumer now fails withInternalnaming the offending type — the same lesson as the SOAPbyte[]` fix, one connector over.
Security
redb.Route.File: the producer would write anywhere the incoming message told it to. The target file name normally arrives from a header (redbFile.Name), 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:ValidatePathis a no-op in the shared base and only the remote transports overrode it. Two ways out of the endpoint directory, both confirmed against the real producer: a relative../escaped.txt, and an absolute path, whichPath.Combinesilently 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, defaulttrue— and throwsUnauthorizedAccessExceptionnaming both the requested and the resolved path.redb.Route.Sftp,redb.Route.Ftp,redb.Route.File: the jail compared a bare string prefix. With a base of/upload/in, the target/upload/instructions/xstarts 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 sharedGenericFileUtils.IsWithinDirectory. The same flaw was inFileClaimCheckRepository.GetSafePath, which is now on the same helper.redb.Route.Grpc: asking a producer for TLS left it connecting in cleartext..Ssl()setsssl=true, but the producer builds its target address fromPlaintext— a separate option that defaults totrueand that nothing linked tossl. OnlynegotiationType=TLShappened to set both. SoGrpcDsl.Call("host:443").Ssl()producedhttp://host:443: the obvious spelling of "use TLS" was ignored, silently, on the leg that carries credentials outward.ssl=truenow impliesplaintext=false, and an explicitplaintextstill wins so local debugging keeps its knob.redb.Route.Soap— WS-Security signature-wrapping bypass fixed. The anti-wrapping check resolved the protected Body withGetElementsByTagName(document order, 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 withredbSoap.signatureValid=true. Verification now resolves the same direct-child Body the route uses and rejects an envelope with more than one Body. Regression test included.redb.Route.Soap— XML-Encryption no longer leaks extra Body children in cleartext.EncryptBodyencrypted only the first<soap:Body>child, so a document/literal body with several elements sent the rest unencrypted. All children are now encrypted under one session key (a singleEncryptedKey/ReferenceList), and decrypt restores them all.redb.Route.As2— the receiver now enforces the partnership's signature/encryption requirements. The inbound handler computedsignatureValidbut never acted on it: an unsigned message (or one whose signature failed) was delivered to the route and answered with a positive MDN. A message that the partnership requires to be signed/encrypted, or whose signature does not verify, is now rejected with a negative MDN and never processed. The crypto (signer pinned to the partner cert) was already correct; only the result was ignored.redb.Route.As2— an unsigned/forged MDN is no longer accepted as a valid receipt.MdnParserdefaultedSignatureValidto true, lowering it only for a signed MDN — so a stripped-signature or fabricatedmultipart/reportread as a valid signed receipt. It now defaults false; only a present-and-verified signature sets it true, andRequireValidMdnhard-fails a send on an unacceptable MDN (negative, MIC mismatch, or missing required signature).redb.Route.As2— SSRF viaReceipt-Delivery-Optionblocked. The async-MDN receipt URL was POSTed to verbatim; a request could point it at link-local metadata (169.254.169.254). Link-local literal IP targets are now rejected before delivery.redb.Route.As2— the async-MDN correlation store no longer grows unbounded.Sweepwas never invoked; a partner that never delivered a promised async MDN leaked one live waiter per message. The store now runs a periodic eviction timer (and is disposed with the component).redb.Route.Http/redb.Route.Grpc— a caller can no longer forge transport headers.HttpConsumersetredbHttp.RemoteAddressfrom the connection and then copied the request headers over it, so a client sending a header literally namedredbHttp.RemoteAddress(a valid HTTP token) replaced the socket address — 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, withallowClientReservedHeaders=trueas an explicit, logged opt-out.
Testing
redb.Route.Tests.GenericFile— the shared file pipeline now has its own suite. The poll loop, filtering, sorting, limits, idempotency, done-file, pre-move / move / delete, the failure contract and the producer write flow are exercised against an in-memoryIFileOperations, so the code that File, FTP and SFTP all share is covered in milliseconds without a disk or a server. Previously it had no suite of its own and was reached only throughredb.Route.Tests.Fileand through docker-gated FTP/SFTP integration tests. The File suite gained end-to-end read-lock tests (the existing ones drove the strategies in isolation, where all five are correct) and producer path-safety tests. Every test added here was confirmed to fail on the unfixed code first.ClaimCheckDslTestscovers the new DSL end to end:Set/Get/GetAndRemove, nestedPush/Pop, and all four repository-resolution paths including the startup failure on an unknown name.- The idempotent-repository suite moved to the class that runs. The two per-connector copies tested
removed types; one of them also pinned the case-folding defect as correct behaviour
(
CaseInsensitive_Keysasserted thatFile.TXTandfile.txtwere the same file). The consolidated suite asserts the opposite, plus concurrency: exactly one of fifty racingAddcalls wins the claim.
Changed (behavioural)
redb.Route.Grpc— the producer throws on a failed call (ThrowOnError, defaulttrue). It used to record theRpcExceptionon the exchange and return; nothing in the pipeline reads that field, so.OnException(...), retry and dead-letter never saw the failure and the route carried on with an emptyOut. It now behaves like the HTTP and SOAP producers. SetthrowOnError=falsefor the old behaviour.redb.Route.Grpc— errors reach clients as gRPC statuses instead ofOKplus an error document. A caller that ignored the status and parsed the body will now see anRpcException. SetsuppressStatusMapping=trueto keep answeringOK.redb.Route.Http.Hosting— conflicting listener settings on one port now throw.ProtocolandclientCertificateModefrom a laterRegisterRouteused to be discarded silently, which put a gRPC route (HTTP/2 only) on an HTTP/1.1 listener and failed every call with an unreadable framing error.redb.Route.Grpc— the consumer now opens a span.grpc receive(ActivityKind.Server,rpc.system=grpc), mirroring the AS2 and SOAP consumers; previously only the producer'sgrpc.invokeexisted. Traces and any span-count assertions will see one more span per call.redb.Route.Grpc—GrpcEndpoint.BuildProducerAddress()composes from host and port instead of echoing the URI path, which now also carries the method address. A URI without an explicit port (grpc:myhost) resolves tohttp://myhost:50051rather thanhttp://myhost.
Dependencies
redb.Route.Grpcno longer referencesGrpc.AspNetCore. The server stack is gone with the wire protocol moving intoGrpcWire; what remains is the message layer (Google.Protobuf, plusGrpc.Toolsas a build-only dependency) and the client channel (Grpc.Net.Client, which bringsGrpc.Core.Api— still used on both sides forStatusCodeandRpcException). The generatedredb_service.protonow emitsGrpcServices="Client": the server side is ours.redb.Route.Grpcnow referencesredb.Route.Http.Hosting, the shared Kestrel host it serves on, joiningredb.Route.Http,redb.Route.As2andredb.Route.Soap.AddRedbRouteGrpc()callsAddRedbRouteHttpHosting()itself (idempotent), so a worker with several HTTP-based transports still ends up with one server manager.
Added
redb.Route.Controllers— SOAP as a controller transport (SoapControllerDispatcher+RedbSoapController). SOAP joins HTTP / SignalR / gRPC as a controller dispatch target:redbSoap.operationmaps to a controller method (by name or[SoapOperation("...")]), the XML body binds to the[FromBody]/ complex parameter viaXmlSerializer, and the typed reply serializes back into the response envelope. Errors become asoap:Fault.From(Soap.Listen("/svc/air")…).RedbSoapController<AirController>();— no HTTP attributes, DTOs may bedotnet-svcutil-generated. Use it in the defaultPayloaddata format. A required simple parameter that cannot bind faults with a readable message (naming the parameter) rather than a reflection error, and abyte[]reply body is emitted as XML instead of"System.Byte[]". Verified end to end through the real SOAP consumer.- Control Bus EIP —
controlbus:component +.ControlBus(...)DSL (Apache Camel parity). Manage routes at runtime by sending a message: start / stop / suspend / resume / restart / status / stats / fail a route addressed byrouteId(currenttargets the sending route). Registered out of the box, producer-only (.To("controlbus:route?routeId=orders&action=stop")or.ControlBus(ControlBusAction.Stop, "orders")). Options mirror Camel:routeId,action,async(fire-and-forget),restartDelay(ms),loggingLevel; plus thecontrolbus:language:<lang>command.statusputs the route'sRouteStatuson the body;statsreturns per-route XML built from the endpoint's realIEndpointStatistics(messages in/out, errors, throughput, health), or the whole context whenrouteIdis omitted. A control action opens aClienttelemetry span like any other connector. Stopping the current route is auto-deferred (async dispatch) so a route can safely stop itself without deadlocking on its own in-flight exchange.
Per-context, matching Camel — for cross-context control, send overFrom("kafka://ingest") .Choice().When(Overloaded).ControlBus(ControlBusAction.Suspend, "current", async: true).End() .To("direct://process");direct-vm/vmto the target context and runcontrolbus:there. Built entirely on the existing per-route lifecycle (StartRoute/StopRoute/ResumeRoute); no new machinery. controlbus:notify— consume route/context lifecycle events as messages (redb extension, beyond Camel). Camel's control bus is producer-only; lifecycle changes are observed through the EventNotifier callback SPI (redb's equivalent isIRouteLifecycleListener). This adds a consumer so events flow into a route and can be handled with the full EIP pipeline:From("controlbus:notify").Filter(...).To("telegram://ops"). EmitsRouteStarted/RouteStopped/RouteSuspending/RouteErrored/ContextStarting|Started|Stopping|Stopped/ExchangeTimedOut, withcontrolbus.event/controlbus.routeId/controlbus.timestamp(+controlbus.error,controlbus.exchangeId,controlbus.elapsedMs) headers. OptionalrouteIdandevents=filters. Events are dispatched off the lifecycle-notification thread, so a slow reaction never stalls route start/stop.redb.Route.Soap— new SOAP / WSDL web-service connector (baseline), oriented to Apache Camelcamel-cxf. Call SOAP services and host SOAP endpoints as ordinary route steps. Schemessoap/soaps. Producer wraps the body in a 1.1/1.2 envelope, POSTs it and surfaces the response onexchange.Out; asoap:Fault(both versions) throwsSoapFaultExceptionwithredbSoap.fault*. Consumer hosts on the shared Kestrel host, delivers the<soap:Body>payload to the route and returns a response envelope (route exception ⇒ SOAP fault; abyte[]reply body is emitted as XML, not"System.Byte[]"). Two header planes handled correctly — transport HTTP headers vs the envelope<soap:Header>block (mapped underredbSoap.header.*), plusredbSoap.operationand the ContentType canonical plane. WS-Security: UsernameToken, XML-Signature of the Body (sign + verify) and XML-Encryption of the Body (encrypt + decrypt) in the standard WSS layout (EncryptedKeyin thewsse:Securityheader with aReferenceListto theEncryptedData, AES-256-CBC + RSA-OAEP), validated end to end by an independent crypto stack (Node.js OpenSSL). On the patchedSystem.Security.Cryptography.Xml9.0.18 (CVE-2026-50648). Signature verification authenticates against the configured partner certificate (rejecting a signature from any other cert) and requires the signature to cover the<soap:Body>(defeating signature-wrapping); the producer decrypts and verifies responses symmetrically. Full connector cross-cutting —Client/Servertelemetry spans,IEndpointStatistics,[Sensitive]redaction, partner config viaSoapConnectionFactory.
camel-cxf dataFormat + MTOM parity:services.AddRedbRouteSoap(); From("direct://q").To(Soap.Call("https://gds/air.svc").ConnectionFactory("amadeus").Operation("GetFares")); From(Soap.Listen("/svc/orders").Host("0.0.0.0").Port(4090)).Process(handle);Payload(default),Message(whole-envelope transparent proxy) andPojo(typed request/response viaXmlSerializer, DTOs may bedotnet-svcutil-generated) — set onSoapConnectionFactory.DataFormat. MTOM/XOP binary attachments asmultipart/related, exposed on theredbSoap.attachmentsplane (SoapAttachment) like Camel'sAttachmentMessage, on both producer and consumer. WSDL publishing (?wsdlparity): a consumer withSoapConnectionFactory.Wsdlset serves the contract onGET ?wsdlwith thesoap:addressrewritten to the caller's URL. Baseline is in-box (HttpClient- shared Kestrel +
System.Security.Cryptography.Xml, no CoreWCF). Verified against an independent SOAP stack (Node.jssoap) in both directions — plain SOAP and MTOM (theirforceMTOMclient ↔ our consumer) — as gatedCategory=Interoptests (harness inC:\Work\yaml\soap). Seedocs/SOAP_CONNECTOR_PLAN.md.
- shared Kestrel +
3.6.0
Why a minor.
.PropagateToolHeaders(...)/?propagateToolHeaders=is new public API, so this cannot be a patch. Everything else here is a fix, and existing routes are unchanged: the option is opt-in and empty by default. The ecosystem moves together —redbcore,redb.Tsakandredb.Identityship 3.6.0 alongside, which also puts the core back on the shared number after the 3.5.1 split.
Fixed
- Fluent DSL builders — endpoint URIs now round-trip through the parser; space-bearing values no longer
corrupt (fixed a
Cron.Schedule(...)crash that took down the whole module). Every fluent builder wrote query values withHttpUtility.UrlEncode(application/x-www-form-urlencoded, where space →+), but the endpoint parser decodes withUri.UnescapeDataString(RFC 3986, where+stays+). The two are not inverse, so any value containing a space came back corrupted. Most visiblyCron.Schedule("job", "0 */5 * * * ?")— every cron expression has spaces — producedschedule=0+*/5+..., andCronEndpointOptions.Validate()then threw anArgumentExceptioninsidecontext.Start(), bringing down the entire module (all routes, not just the one job). The same latent mismatch affected SQL statements, LDAP filters, Exec arguments, LLM prompts and any other space-bearing DSL value. Fixed at the writer side across all builders (HttpUtility.UrlEncode→Uri.EscapeDataString, which writes%20and round-trips cleanly). The parser is deliberately untouched: hand-written endpoint URIs and existing routes are byte-for-byte unaffected, andSystem.Webis dropped as a dependency. Guarded by aBuild()→Parse()round-trip test so the invariant "write it the way the parser reads it" cannot silently regress again. redb.Route.Llm— conversation isolation no longer depends on tree-filter semantics (cross-conversation leak + tree corruption).RedbConversationStore.LoadPathAsyncdetected the conversation head withTreeQuery(root).WhereLeaves(), trusting the rooted query to scope the subtree. It did not: the core evaluated it as "freshest leaf of the whole schema" (fixed for Pro ind88ff9fe, still open for the Free PVT routing), so any turn of any conversation could load whoever wrote last globally — and the next message was then attached under that foreign node, corrupting_id_parentirreversibly. Head detection now goes through the conversation FK the connector already stamps (value_longon the indexed_objectsrow): the newest message of a conversation is always a leaf, soWhereRedb(o => o.ValueLong == rootId)ordered bydate_createis exactly "the freshest leaf" without depending on how a provider implements tree filters. The FK was always stamped correctly, so this also recovers already-corrupted trees on read. Three further defects in the same file, independent of the core:AppendAsync/LoadPathAsync(convId, leafId)resolved a message id across the whole schema, so an application that lets clients branch by message id could read — and write into — another conversation. The lookup is now scoped by the conversation FK and fails loudly.- The foreign conversation root slipped into the transcript as a role-less
MessageProps(the path was trimmed byid != rootId, which only recognises its own root), posting an empty role to the provider — a 400 that reads like a model problem. - The root-id cache was keyed by conversation id alone while the redb instance is chosen per
exchange (
?redb=), so with two named databases a root resolved in A was returned for B. Keyed by(instance, conversation)now.
redb.Route.Llm— tool routes now run on a child of the agent exchange, not on a naked one.AgentEngine.DispatchToolEndpointAsyncbuilt the tool's exchange from scratch (new Message(...)+IProducerTemplate.RequestBody), so a route mounted with.AsLlmTool(...)/[ExposeAsLlmTool]/ MCP received noProperties(includingLlmKeys.RedbName), noRouteIdand a fresh DI scope from the root container instead of the conversation's. Every scoped service the agent route had resolved — principal, tenant accessor, per-exchangeIRedbService— came back empty inside the tool. Only the ambient transaction survived, because it flows through the async context. Dispatch now usesparentExchange.CreateLinkedChild(msg)+IProducerTemplate.RequestAsync, which is whatdocs/LLM/PLAN.md §4.1specified and whatILlmToolDescriptor/RouteToolBridgedocumented. The child shares the parent's scope without owning it, so it releases only the__redb_scope:*entries the tool itself opened and the conversation keeps its scope after the call. Tool dispatch is sequential within an iteration, so the shared scope carries no concurrency risk.redb.Route.Llm— the run's principal and audit tags reach tools. The engine copied a hard-coded three-header allowlist (llm.conversation.id,X-Correlation-Id,CorrelationId) and nothing else, so a "who is asking" tool had no way to learn the subject except asking the model for it — an argument the model can be talked into forging.llm.user.idandllm.audit.*are now propagated as the values resolved for the run, not as raw headers:?user=${header.X-User-Id}and?audit=reach tools too, which a header copy would have missed.
Added
redb.Route.Llm—.PropagateToolHeaders(...)/?propagateToolHeaders=. Opt-in list of extra header names forwarded from the agent exchange to every tool call; a trailing*makes an entry a prefix match. Also on the inline step asLlmCallBuilder.WithPropagatedToolHeaders(...)..To(Llm.Factory("claude") .Tools("profile_state,billing_check") .User("${header.X-User-Id}") .PropagateToolHeaders("x-tenant-id", "accept-language"))Propagation stays default-deny — the inbound transport's header set is never forwarded implicitly, so an HTTP consumer's
Authorization/Cookiecannot ride into a tool by accident. The policy lives in the new publicToolHeaderPolicy, andAgentRequest.PropagateToolHeaderscarries it to the engine.Note the trust model this assumes:
llm.user.idandllm.audit.*are read off the inbound exchange byLlmProducer, so a route that forwards client headers verbatim lets the caller set them. That was already true for what the engine persists inMessageProps.UserId; a route whose tools make access decisions on the principal must strip or overwrite client-suppliedllm.*headers before thellm://hop.
3.5.1
Why a separate patch instead of shipping inside 3.5.0. The AS2 connector and the shared Kestrel host landed on
developafter 3.5.0 had already been published to nuget.org (2026-08-05, 61 packages). A published version cannot be replaced, so these two packages — and theredb.Route.Httpbuild that goes with them — ship as 3.5.1.
redb.Route.Httpis republished here on purpose.SharedHttpServerManagermoved out of it intoredb.Route.Http.Hosting, so the 3.5.0 build on nuget.org still carries its own private copy of the multiplexing server. Mixing that 3.5.0 withredb.Route.As2would put two Kestrel managers in one process — andredb.Route.As2does not referenceredb.Route.Http, so NuGet would never upgrade it for you. Takeredb.Route.Http3.5.1 whenever you use AS2 on a shared port; the public API of the package is unchanged either way.The whole
redb.Routeline moves to 3.5.1 together, so no combination of 3.5.x Route packages can mix the two hosting models.redbcore,redb.Tsakandredb.Identitystay on 3.5.0 — the shared-runtime compat gate compares the minor, and 3.5.0 ↔ 3.5.1 is patch drift, which it allows.
Added
redb.Route.As2— new AS2 (RFC 4130) B2B/EDI connector. Exchange business documents with trading partners over HTTP(S) using signed and encrypted S/MIME messages and MDN receipts. Schemesas2/as2s. Both directions, synchronous and asynchronous MDN, signed MDN, and the standard signature / encryption algorithm matrix (sha-1/256/384/512×aes-128/192/256-cbc,3des, optional RFC 3274 compression). Crypto is MimeKit (Bouncy Castle) — the same foundation the AS2 industry interoperates on.services.AddRedbRouteAs2(); context.AddToRegistry("walmart", new As2ConnectionFactory { OurCertificate = ourPfx, PartnerCertificate = theirCer, As2From = "OUR-ID", As2To = "WALMART-ID", PartnerUrl = "https://partner/as2", Sign = true, Encrypt = true, SignedMdn = true, MdnMode = As2MdnMode.Sync }); From(As2.Receive("/inbound").Host("0.0.0.0").Port(4080).ConnectionFactory("walmart")) .To("direct://process-edi"); // receive server From("direct://out") .To(As2.Send("https://partner/as2").ConnectionFactory("walmart")); // send + verify MDN- Partner config via
As2ConnectionFactoryin the registry — certificates, AS2 IDs and the agreed profile referenced by.ConnectionFactory("name"), never in the URI. - MDN outcome on
exchange.Outfor a producer:redbAs2.mdnDisposition,redbAs2.signatureValid,redbAs2.mdnMicMatch(the partner received exactly what we sent). Async MDN usesAs2.ReceiveMdn(path)with correlation byOriginal-Message-ID. - Received document on the consumer: business content type on
Message.ContentType(the S/MIME wrapper type is deliberately kept off the headers), AS2 headers verbatim, computed MIC underredbAs2.mic. - Full connector parity —
IEndpointStatistics/ health in Tsak,Consumer/Clienttelemetry spans,[Sensitive]secret redaction. - Interop validated against a live OpenAS2 v4.9.0 in both directions (
redb → OpenAS2andOpenAS2 → redb, signed + encrypted, positive MDN, MIC verified). Seesrc/redb.Route.As2/TESTING.md.
- Partner config via
redb.Route.Http.Hosting— new shared Kestrel hosting package.SharedHttpServerManager(the multiplexing HTTP server, one Kestrel perhost:port) was extracted fromredb.Route.Httpinto a standalone package so HTTP-based connectors (redb.Route.Http,redb.Route.As2, …) share one server without depending on each other. Register once withservices.AddRedbRouteHttpHosting()(idempotent); every connector resolves the same singleton. No breaking change — the types keep theredb.Route.Httpnamespace, soredb.Route.Httpsource and public API are unchanged.
3.5.0
Why a minor (3.4 → 3.5). This release adds public API: the Message History, XSLT and Routing Slip EIPs, and
{{key}}/{{key:default}}property placeholders in endpoint URIs. Backward-compatible — existing routes are unchanged, and every new feature is opt-in (Message History is off until.MessageHistory()orEnableMessageHistory). The ecosystem number moves together:redbcore,redb.Tsakandredb.Identityall ship 3.5.0 alongside — see the rootCHANGELOG.mdfor why the runtime cannot stay a minor behind the framework.The AS2 connector and the shared Kestrel host were written against this version but merged after it was published; they ship in 3.5.1 — see above.
Added
- Message History EIP —
.MessageHistory()/RouteEngineOptions.EnableMessageHistory. Records the trail an exchange takes through a route: each node is timed and appended toexchange.Properties["CamelMessageHistory"]as aMessageHistoryEntry(route id, node id, label, elapsed ms), and the trail is dumped on failure (when retries are exhausted) so the failing step is visible. Apache Camel parity: disabled by default (slight overhead), enabled globally (EnableMessageHistory) or per route with.MessageHistory(bool)— a route override wins over the global setting. Read the trail withMessageHistory.GetEntries(exchange)/ render it withMessageHistory.Format(exchange). The elapsed time is recorded even when a node throws.From("direct://orders").MessageHistory() .Process(validate) .To("http://fulfil"); // on failure the log shows: routeId · to1 · to · 12.400 (etc.) - XSLT transformation —
.Xslt(...)/.XsltContent(...)and thexslt:component. Transforms the exchange body through an XSLT stylesheet, Apache Camelxslt:parity. Modelled exactly on the built-in validator (leaf DSL and a component, engine behind an interface): the engine isIXsltEnginewith a built-inXslCompiledTransformEngine(BCLSystem.Xml.Xsl, XSLT 1.0, zero external dependencies — same as Camel's default JAXP engine; a Saxon-backed engine for 2.0/3.0 can be added later as anotherIXsltEngine, exactly as third-party validators live inredb.Route.Validation.Adapters). The stylesheet is compiled once when the route is built and reused.From("direct://orders").Xslt("styles/orders.xsl"); // from a file (imports resolve) From("direct://orders").XsltContent(inlineStylesheet, XsltOutput.Bytes); From("direct://orders").To("xslt:styles/orders.xsl?output=bytes"); // component, registered out of the box- Parameters — all message headers and exchange properties are passed to the stylesheet as
xsl:param(a stylesheet sees the ones it declares), matching Camel. - Input body —
string,byte[],Stream,XmlReader,XDocument/XElement, orIXPathNavigable. output—string(default) /bytes/dom.failOnNullBody(default true).allowTemplateFromHeader— when set, a per-message stylesheet from theCamelXsltResourceUri/CamelXsltStylesheetheader overrides the compiled default (dynamic stylesheets are compiled once and cached).- The
xslt:component is registered out of the box —.To("xslt:...")needs no setup, and it composes as an endpoint (a Routing Slip / Recipient List can targetxslt:). - Security: script and
document()are disabled (BCLXsltSettings.Default) for stylesheets from outside the app. Content-Typeis left unchanged after the transform (matching Camel).output=fileis intentionally not provided — route the transformed body to the File connector (.To("file:...")) instead.
- Parameters — all message headers and exchange properties are passed to the stylesheet as
- Routing Slip EIP —
.RoutingSlip(...). Pipes the exchange through a list of endpoints that is computed once up front (contrast the Dynamic Router, which recomputes the next hop after every step). The slip may be a factory delegate, anIExpressionyielding a delimited URI string, or a${...}template — Apache Camel parity, including the default,delimiter andignoreInvalidEndpoints. The same exchange is threaded through the endpoints in sequence with Pipeline semantics (each hop'sOutbecomes the next hop'sIn; the finalOutis left for an InOut caller), and the current endpoint is exposed on the exchange propertyCamelSlipEndpoint. Producers are cached per URI.From("direct://orders") .RoutingSlip("${header.route}") // e.g. "direct://validate,direct://enrich,amqp://out" // or a factory: From("direct://orders") .RoutingSlip(ex => new[] { "direct://validate", "direct://enrich", "amqp://out" }); - Property placeholders in endpoint URIs —
{{key}}/{{key:default}}. Apache CamelPropertiesComponentparity: endpoint URIs (and any string routed throughGetEndpoint) externalise their configuration instead of hard-coding hosts, queue names, ports or paths. Placeholders are resolved once at route-compile time — values come fromIConfiguration(environment variables, appsettings, user-secrets — with the container's own precedence, so no Camel-styleenv:/sys:prefix functions are needed), then the context's own properties (SetProperty) as a container-free fallback. A placeholder with neither a value nor a default fails fast at compile time. Resolution is single-pass and a URI without{{is untouched, so existing routes are unaffected.
Deliberately minimal (matching the redb.Route "no gold-plating" line): no nested placeholders, noFrom("{{orders.source}}") // e.g. amqp://broker/orders, from appsettings .To("http://api/{{tenant}}/pay"); From("amqp://broker:{{amqp.port:5672}}/orders"); // default when the key is not configured{{?optional}}parameter removal, no encryption —IConfigurationalready covers the layered-source need.PropertyPlaceholderResolveris public for reuse. redb.Route.IbmMq— event-driven receive via XMSMessageListener, cutting delivery latency from ~250–500 ms to single-digit ms. The default consumer polls with a blocking MQGET-WAIT on the IBM.WMQ managed client, which carries an internal ~500 ms tick independent ofwaitInterval— so an idle/low-traffic queue delivered messages ~250–500 ms after they arrived. The managed IBM.WMQ client exposes no public async-consume API (there is noMQQueue.Cb/MQQueueManager.Ctlin any 9.4.x — verified by reflection), so the fix uses XMS .NET (IBM.XMS), the JMS-style client that does support event-drivenMessageListenerpush. New opt-in optionreceiveMode=listener(default stayspoll) activates it; measured publish→receive on a local broker dropped to ~2–6 ms.
The listener runs the route synchronously on the XMS dispatch thread — one session = one in-flight message = natural back-pressure. The package reference changed fromr.From("wmq:ORDERS?queueManager=QM1&channel=DEV.APP.SVRCONN&receiveMode=listener")IBMMQDotnetClientto its supersetIBMXMSDotnetClient(sameamqmdnetstd.dllfor IBM.WMQ +amqmxmsstd.dllfor IBM.XMS; same vendor and version), so the default poll path is byte-for-byte unchanged.- Transacted listener (
receiveMode=listener&transacted=true) uses a JMSSESSION_TRANSACTEDsession and commits/rolls back on the delivering session — a processing error rolls back and the broker redelivers, exactly like the poll path's connection syncpoint. AnIbmMqXmsAckActionis registered on the exchange so a route-level.Transaction()block settles it as part of the route unit-of-work (mirroring the poll path'sIbmMqAckAction); if the route has no transaction block the engine settles the session itself (commit on success, rollback on error). The action is idempotent, so the two paths never double-settle. concurrentConsumers=Ncreates N XMS sessions on the shared connection — N competing consumers processing in parallel (each session single-threaded, queue-manager load-balanced), one in-flight per session for back-pressure. A topic clamps to a single subscriber (parallel subscriptions would duplicate delivery), same rule as the poll path.- Request-reply, backout threshold and W3C trace propagation all work on the listener path, at
full parity with the poll path. A request carrying
JMSReplyTogets anInOutexchange and theOutbody is sent back correlated by message id (non-persistent, so temporary reply queues accept it); a message pastbackoutThresholdis copied tobackoutQueue; andtraceparent/tracestateon the message continue the distributed trace. RPC reply and the backout copy are issued on the delivering session, so undertransactedthey commit atomically with consuming the request. - RPC client leg is event-driven too. With
receiveMode=listenerthe producer's request-reply now receives the response through an XMSMessageListeneron the reply queue instead of the poll loop that carried the ~500 ms tick, so the whole round-trip is fast (measured ~16 ms warm vs the poll floor of ~250–500 ms). The request is still sent over IBM.WMQ; only reply reception moves to XMS. A dynamic reply queue uses an XMS temporary queue (owned and consumed by the producer's own connection); a configuredreplyToQueueis used as-is. - Header parity on the listener path. The XMS consumer and the event-driven RPC reply now carry
the same headers as the poll path: the
redbIbmMq.*MQMD metadata (Destination, QueueManager, MsgId, CorrelId, Priority, BackoutCount — mapped from the JMS/MQMD properties, gated bymqmdReadEnabledlike poll) and the application (user) headers. - Fluent
.Listener()/.ReceiveMode(...)on the builder mirror thereceiveModeURI option.
- Transacted listener (
redb.Route.IbmMq— user-header catalogue moved to the JMSusrfolder (interoperability fix). User headers are carried as a single JSON property (MQ property names forbid hyphens, soX-Custom-Idcan't be an individual property). That property was written under a dotted name (redbIbmMq.HeaderKeys), which MQ places in a custom MQRFH2 folder that JMS clients — including IBM.XMS and any other JMS consumer — do not surface as a user property, so headers were invisible off the IBM.WMQ path. It now uses an unqualified name (redbIbmMqHeaders) that lands in the standardusrfolder, readable by IBM.WMQ, IBM.XMS and any JMS client alike. Wire-format note: a producer on this version and a consumer on an older one (or vice-versa) will not exchange user headers across the change — deploy producer and consumer of the IBM MQ connector together. MQMD metadata and message bodies are unaffected.
Changed
- Internal: unified the scope-body pipeline builder (
NodePipeline). Every scope definition (Choice, Filter, CircuitBreaker, TryCatch, Loop, Metered, Replayable, Aggregate, IdempotentConsumer, Resequence, Threads, Traced, Transaction, Throttle, Debounce, Split) carried its own identical copy of the "0 → no-op / 1 → the child / N → a pipeline" logic; these now route through a singleNodePipeline.Body/Node. Behaviour is unchanged (verified by the full suite, incl. 70 transacted tests), and it provides the single compile-time seam that Message History (above) decorates each node through. No public API or DSL change.
Fixed
redb.Route.Http— the fluentHttp.Listen("/path").Host(..).Port(..)dropped the first path segment. The builder emits host/port as query parameters (http:/api/honest/echo?host=..&port=..), so the whole leading-slash path is the route — butHttpEndpoint.ConsumerPathassumed any leading-slash path was the nonstandard/host:port/routeform and stripped its first segment, registering/honest/echoinstead of/api/honest/echo(and/webhookcollapsed to/). It now strips the first segment only when it is an actual embedded host (recognised by a:in that segment — route segments have none), soHttp.Listen("/api/honest/echo").Host("0.0.0.0").Port(5092)registers the full path. The equivalent URI DSL (http:0.0.0.0:5092/api/honest/echo) was already correct and is unchanged.
3.4.0
Why a minor bump (3.3 → 3.4). This release adds public API surface, not just fixes: replay checkpoints (
.Replayable,IExchange.Snapshot,IRouteContext.ReplayAsync/GetReplayMarkers), the reusable scope-nesting validation primitives (ICompositeScope/IDurableScope/IScopeNestingRule/IBranchingDefinition), namedConnectionFactoryon every connector, andProducerTemplateexchange-typed overloads — alongside the endpoint-URI secret-redaction security hardening. The whole ecosystem ships at 3.4.0. Backward-compatible: existing routes are unchanged.
Security
- Endpoint-URI secrets no longer leak into logs, telemetry, health checks, or the Tsak
dashboard. Credentials carried in an endpoint URI — a query-parameter (
?password=,?bindPassword=,?sessionToken=,?saslPassword=,?connectionString=, …) or a userinfo password (amqp://user:pass@host) — were being written in cleartext at route-build and endpoint-start (Compiled route …,Endpoint … started), in the OpenTelemetryredb.route.endpointspan tag and metric label, in the inflight-exchange and health-check metadata, and in theCompiledRoute.FromUriDTO that the Tsak CLI/dashboard render. The masking that did exist was bypassed by these raw-string paths and, where it ran, (a) disclosed the first two characters of every secret and (b) used an exact-match deny-list of eight names — sobindPassword,sessionToken,sslKeyPassword,authToken,clientSecret,privateKeyPassphrase,sharedAccessKey, and userinfo passwords all slipped through. Fixes:- Full redaction to a constant
****— no more first-two-character disclosure. - Substring-based secret detection replacing the exact-match list (covers the names above and
any
*password*/*token*/*secret*/*apikey*/*credential*variant); benign params likeroutingKey/partitionKey/clientId/usernameare deliberately never masked. - Userinfo passwords are masked (
user:pass@host→user:****@host) — previously outside the masker entirely. - Every core log / telemetry / metric / health-check / DTO boundary is routed through a new
format-preserving
EndpointUri.Sanitize(string)(keeps scheme,://, path, param order, and non-secret values byte-for-byte). Unnamed routes now derive a sanitized route id, so a secret can no longer surface through{RouteId}log lines. redb.Route.Elasticsearch— theNodes=startup log now sanitizes each node URL (userinfo).redb.Route.Exec— the debugexec →line logs the executable and argument count only; argument values (which routinely carry secrets on the command line) are no longer emitted.- Producer/consumer start-stop lines that printed the raw endpoint key are sanitized —
GenericFileProducer(the base for FTP/SFTP, whose credentials live in query parameters, so?password=was reaching the log verbatim) plus the in-process Direct / SEDA / Mock / Log components. - New public API on
EndpointUri:Sanitize(string),IsSensitiveKey(string), andAddSensitiveKeys(params string[])for connectors to register non-standard secret parameter names (analogous to Camel'saddSanitizeKeywords). [Sensitive]on an endpoint option is now the source of truth for what gets redacted. Guessing a secret from its parameter name fails open — that is exactly howbindPassword,sessionTokenandsslKeyPasswordleaked: they were credentials nobody had put on the list. An option marked[Sensitive]is redacted because it was declared one:public string Server { get; set; } = "localhost"; // printed in logs [Sensitive] public string? BindPassword { get; set; } // always ****EndpointOptions.BindFromUriharvests those declarations by reflection (once per options type) and feeds them intoEndpointUri.AddSensitiveKeys, so the keyword set is derived from the code, never hand-maintained and a newly added credential option cannot be forgotten. This mirrors Apache Camel, where@UriParam(secret = true)is the declaration and the runtime list (SensitiveUtils.SENSITIVE_KEYS) is generated from those annotations by a build plugin; the .NET version needs no build step, only reflection. All 37 credential options across 22 connectors are annotated. The name-keyword heuristic remains as a backstop for a URI rendered before any endpoint of that scheme has been created.- Display-only change: routing identity, endpoint cache keys (
NormalizedKey/BaseKey), and message flow are unaffected.CompiledRoute.FromUriis now a redacted display value and must not be re-parsed to recover credentials.
- Full redaction to a constant
redb.Route.Ldap—connectionFactoryis now actually resolved: newLdapConnectionFactory.LdapBuilder.ConnectionFactory()andLdapEndpointOptions.ConnectionFactoryexisted since 3.3.x but nothing ever read them —LdapEndpointtookBindDn/BindPasswordstraight off the URI, so a service-account password had to be written into the route and from there reached logs and the dashboard.LdapEndpointnow resolves the namedLdapConnectionFactoryfrom the route registry and fills in every connection/credential option the URI did not set (an explicit URI value still wins, so existing routes are unchanged; a missing factory logs a warning and falls back to URI parameters). A route can now carry no credentials at all:context.AddToRegistry("honest-ldap", new LdapConnectionFactory { Server = "ldap.corp.local", Port = 636, Ssl = true, BindDn = "cn=svc-reader,dc=corp,dc=local", BindPassword = Environment.GetEnvironmentVariable("LDAP_BIND_PASSWORD") }); r.From("ldap://SEARCH:dc=corp,dc=local?connectionFactory=honest-ldap&filter=(objectClass=user)")- Secrets embedded in exception messages are redacted before logging.
OnExceptionProcessorlogsex.Messageon redelivery and retries-exhausted; a driver exception can carry a connection string (...;Password=…;…). Those two sites now run the message through the newEndpointUri.RedactSecrets(string), which maskskey=valuesecret assignments inside arbitrary text while preserving everything else. Note: whenLogStackTraceis enabled the exception object itself is handed to the logger and cannot be scrubbed in-process — useRedactSecretsin a logging-sink filter for that path.
Added
- Replay checkpoints —
.Replayable("name")save-points. A named point in a route that snapshots the exchange as it passes, so the tail of the route (everything after the marker) can be re-run later from that frozen state — e.g. the platform replaying a failed exchange from the last successful step instead of from the mangled current state. The capturedRouteCheckpointlands inexchange.Properties["route.checkpoint"](last marker wins) and replay is a typed in-process callIRouteContext.ReplayAsync(routeId, markerName, snapshot). Full developer guide (incl. the Tsak-integration contract):docs/REPLAY_CHECKPOINTS_GUIDE.md.From("timer://poll?period=5000") .Process(chargeCard) .Replayable("after-charge") // save-point: card already charged .Process(sendReceipt) .To("http://receipts") .EndReplayable();IExchange.Snapshot()/IMessage.Snapshot()— a deep, isolated copy distinct fromClone(): the body is deep-copied so the captured state is frozen against later in-place mutation (whereasClone()intentionally shares the body — relied upon by e.g. Splitter aggregation). v1 handles immutable /byte[]/ICloneablebodies and throws loudly otherwise (never a silent shallow share). A snapshot carries no DI scope (dormant data — no per-message leak). Also corrected the misleading "Deep copy" doc onClone().exposed: trueadditionally publishes the marker asdirect:__replay:{routeId}:{name}so other routes can.To(...)it; the default (exposed: false) is reachable only viaReplayAsync.IRouteContext.ReplayAsync/GetReplayMarkersandProducerTemplateexchange-typed overloads (Send/SendAsync/RequestAsync(..., IExchange, ct), caller-owned) round out the API.- A non-snapshot-able body degrades gracefully (warn, no capture) — checkpoints never break the
happy path. Routing identity, cache keys, and existing
Clone()behaviour are unchanged.
- Reusable scope-nesting validation. A general mechanism (not an ad-hoc per-type check) for
expressing where a definition may/may not nest:
ICompositeScope/IDurableScopescope-category markers,IScopeNestingRule(a node declaresAllowed/Warn/Forbidagainst an ancestor category), andIBranchingDefinition(definitions whose children live outsideOutputs— Choice When/Otherwise, TryCatch catch/finally — expose them for a generic tree-walk). The validator applies the rules generically:Forbid→ build error,Warn→ log. First use: a replay checkpoint may not cross a branching composite (build error) and warns inside a durable transaction (replay runs outside it). New structural constraints ship on the definition, never in the validator. - Named
ConnectionFactoryfor connectors that previously had no way to keep credentials out of the endpoint URI. Registered in the route registry and referenced by name (?connectionFactory=my-bot), so the secret never enters the URI at all — nothing to mask in logs, telemetry, or the dashboard. Each factory fills only the options the URI did not set explicitly, so an inline URI value always wins and existing routes are unchanged; a name that is not in the registry logs a warning and falls back to URI parameters. Rolled out to all ten connectors that previously had no such mechanism: Telegram (TelegramConnectionFactory— bot token; token-less DSL overloadsTg.Receive().ConnectionFactory("bot")), MqttNet (MqttConnectionFactory— broker address + username/password/TLS), Http (HttpConnectionFactory— Basic/Bearer credentials, TLS certificate password, timeout;AuthTokensupports${...}expressions exactly like the URI form; the request address deliberately stays in the URI path so a factory can never silently redirect a route), Mail (MailConnectionFactory— one factory shared by SMTP / IMAP / POP3: mailbox username/password, OAuth2 access token, auth mechanism, transport security and client certificate; a host taken from the URI path is never overridden by the factory), Ftp / Sftp (FtpConnectionFactory/SftpConnectionFactoryover a sharedRemoteFileConnectionFactorybase inredb.Route.GenericFile— host/port/username/password and timeouts in the base, FTPS settings for FTP, private-key path + passphrase, host-key checking and proxy credentials for SFTP), SignalR (SignalRConnectionFactory— hub access token, transport and TLS material), and Grpc / Tcp / WebSocket (GrpcConnectionFactory/TcpConnectionFactory/WsConnectionFactory— TLS certificate password and connect timeouts). For the connectors whose address lives in the endpoint path (Http, Mail, SignalR, Grpc, Tcp, WebSocket) the factory deliberately carries no host/port, so it can never silently redirect a route; thewssscheme likewise still forces TLS on regardless of the factory. A fluent.ConnectionFactory("name")was added to every builder that has one (Telegram also gains token-less mode overloads); SignalR and WebSocket are URI-only and unchanged in that respect.context.AddToRegistry("support-bot", new TelegramConnectionFactory { Token = Environment.GetEnvironmentVariable("TELEGRAM_TOKEN")! }); r.From("telegram://receive?connectionFactory=support-bot") // no token in the route redb.Route.Telegram— reply target as a first-class producer option:replyToMessageId(expression-capable) with fluentReplyTo(long)/ReplyTo(IExpression)/.ReplyToIncoming(). Replying to the message that triggered the exchange previously required a manual.Processstep copyingtelegram.messageIdintotelegram.replyToMessageId. The option accepts a constant id or a${...}expression resolved per message;.ReplyToIncoming()is sugar forreplyToMessageId=${header.telegram.messageId}. An explicittelegram.replyToMessageIdheader still wins; an expression that resolves to nothing sends the message unthreaded. A constant that is not a message id fails validation at endpoint start. Applies tosend/document/photo.redb.Route.Telegram— edit/delete target as an option:messageId(expression-capable) with fluentMessageId(long)/MessageId(IExpression). Chaining send → edit previously required a manual copy oftelegram.sentMessageIdintotelegram.messageId; nowTg.Edit(token).MessageId(Header(TelegramHeaders.SentMessageId))does it. The header still wins.redb.Route.Telegram—answermode supportsshowAlert(URI option,.ShowAlert()fluent, per-messagetelegram.showAlertheader): the callback answer is shown as a modal alert instead of a toast.redb.Route.Telegram— per-messagetelegram.captionheader fordocument/photo, wins over thecaptionoption (consistent withparseMode/fileName).redb.Route.Telegram— Mini App payloads (WebApp.sendData) are now surfaced: headerstelegram.webAppData/telegram.webAppButtonText. Aweb_app_datamessage carries no text, so the consumer previously handed such an update to the route with an empty body and no way to reach the payload short of parsing the rawUpdate. The data now becomes the exchange body — same contract as text messages and callback queries — and is also exposed as a header. Applies to both the long-polling and webhook paths (sharedTelegramUpdateMapper);telegram.messageTypeis"WebAppData"for filtering.
Fixed
redb.Route.Telegram—document/photomodes silently ignoredtelegram.replyToMessageIdandtelegram.replyMarkup. Both headers were documented in the producer-headers table but only wired intosend, so a photo with inline buttons or a document sent as a reply lost its markup / reply target. Both modes now pass them to the Bot API.
3.3.3
Why the bump. No functional changes to redb.Route — this is an ecosystem sync release, and the whole family is published at 3.3.3 (all 34 packages).
Two reasons:
- The family had drifted apart. Base sat at 3.3.1 while
SqlandSqswere at 3.3.2 from the partial release below — so a shared-layer install mixed3.3.1and3.3.2archives side by side, and "which versions go together" needed a table. From 3.3.3 every package in the ecosystem — redb core, redb.Route, redb.Tsak — ships one number.- It picks up
redb.Core3.3.3. Not every package depends on redb storage —redb.Routeitself and the transports (Kafka, RabbitMQ, …) do not. Butredb.Route.Coreandredb.Route.Llmdo, and at 3.3.1 both pinnedredb.Core3.3.0, whose embeddedredb_init.sqlfailed schema initialization under a non-superuser database owner (see the redb core changelog). Sinceredb.Route.Coreis the package a redb-backed route worker is built on, that broken init reached any deployment on a least-privilege database. All packages are rebuilt againstredb.Core3.3.3.The
Sql/Sqsfeatures listed under 3.3.2 below are not re-announced here — they shipped in 3.3.2 and are unchanged; those packages only change number.Also from this release, the shared assembly layer is distributed as one archive per OS (
redb-route-shared-3.3.3-<rid>.zip, all 32 connectors inside) instead of one archive per connector. Seepublish/ARCHITECTURE.md.
redb.Route.Sql 3.3.2, redb.Route.Sqs 3.3.2
Partial release. Only these two connector packages are published at 3.3.2; the rest of the family — including
redb.Routeitself — stays at 3.3.1 and is unchanged. Both packages depend onredb.Route >= 3.3.1, so they drop into an existing 3.3.1 install without touching anything else.
Fixed
redb.Route.Sql—mode=Procedurenever took the procedure name from the URI path, which made the fluentSql.Procedure(...)builder unusable.SqlBuilder.Build()only ever emits the name into the URI path, whileSqlEndpointOptions.Validate()demanded a separateprocedureName=parameter — so everySql.Procedure("sp_x")route threwArgumentException: ProcedureName is required for Procedure mode.at endpoint creation, and the string-URI form had to repeat the name twice (sql:sp_x?mode=Procedure&procedureName=sp_x).SqlComponent.CreateEndpointnow falls back to the URI path whenprocedureName=is absent, which is what theSqlEndpointxml-doc already promised ("The path part of the URI is the SQL query or stored procedure name"). An explicitprocedureName=still wins, so nothing that works today changes behaviour. The bug survived becauseSql.Procedureappeared in the docs but in no test and no route; an end-to-end test through the realEndpointUriParsernow covers it.
Added
redb.Route.Sql—outputClassnow actually maps rows to a POCO. The option was bound from the URI and silently ignored:PocoRowMapper<T>existed but was never instantiated, and every result came back asDictionary<string, object?>. The newSqlRowMapperFactoryresolves the type name (assembly-qualified, full, or short against loaded assemblies), verifies a public parameterless constructor, and caches the mapper. Producer:SelectList→ a typedList<T>,SelectOne→T,StreamList→IAsyncEnumerable<T>. Poll consumer: the message body becomes the mapped POCO. The consumer still maps the raw row dictionary alongside the POCO, because header population and the@nameauto-bind inonSuccess/onFailurerun off the raw columns — a POCO would silently drop any column it has no property for. An unresolvable type name now fails loudly instead of being ignored. Unset (the default) → dictionaries, exactly as before.redb.Route.Sql—outputHeadernow delivers the result to a header instead of the body. Also previously bound and ignored (SqlProducer.SetResultcarried a "Check if result should go to header or body" comment and unconditionally wrote to the body). WithoutputHeader=nameset, the query result lands in that header and the incoming body is left intact — which is what makes a SQL lookup usable as an enrichment step rather than a payload-destroying one. Supported forSelectList,SelectOne,Scalar,StreamList, and formode=Procedure&asFunction=true. Unset → result replaces the body, exactly as before.redb.Route.Sqs— SNS raw message delivery on the SNS→SQS auto-subscription. NewrawMessageDeliveryoption (Sns.Topic(...).SubscribeSnsToSqs(arn).RawMessageDelivery(), orrawMessageDelivery=truein the URI). When the SNS publisher auto-subscribes an SQS queue (subscribeSnsToSqs=true), it now also sets the subscription'sRawMessageDelivery=true, so the queue receives the bare payload — and SNS message attributes map to SQS message attributes, restoring W3C trace continuity across the hop — instead of the default SNS JSON notification envelope ({"Type":"Notification","Message":...}) which the subscriber would otherwise have to unwrap. Default remainsfalse(AWS-compatible envelope).
Changed
redb.Route.Sql— README rewritten URI-first, and corrected where it contradicted the parser. It documented SQL placeholders as:id/:body, butSqlParameterParseronly ever recognised@name, and there is no implicit@body— a scalar body never binds itself into a parameter (useparam.msg=${body}), and an unmatched placeholder silently becomesDBNull. The single URI example also omitted the mandatorymode=Pollfor a consumer. The README now leads with string URIs for all three modes, documents the parameter-binding priority, and flags what remains unimplemented. Seedocs/SQL_PROCEDURE_MODE_REGRESSION.mdfor the full audit, including the options left alone on purpose (transactedis a no-op on producers, which always open a local transaction;batchSizeis an on/off flag, not a chunk size;SqlHeaders.GeneratedKeysis never set but is kept because removing a public constant would break consumers).
redb.Route 3.3.1
Fixed
- RabbitMQ — full AMQP basic-property round-trip (regression fix). The producer forwarded only
CorrelationIdfrom headers — and by a bare name that didn't match what the consumer stamped — silently droppingReplyTo/MessageId/Priority/Expiration/Type/AppId/UserId/Timestamp/ContentEncoding/DeliveryModeon a consume→produce hop. The producer now maps every settable string/byteBasicPropertiesfield from headers via cached reflection (plus explicitTimestamp— anAmqpTimestamp, notIConvertible— andPersistent/DeliveryMode); the consumer stamps them symmetrically. Standard properties now use their bare well-known names (ReplyTo,Priority, …) so a round-trip carries them through with no docs needed;redbRmq.*is still accepted on the producer for back-compat and remains the prefix for delivery metadata (Exchange/RoutingKey/DeliveryTag/Redelivered/ConsumerTag). - AMQP 1.0 — property forwarding +
CorrelationIdround-trip. The producer now forwardsMessageId/CorrelationId/ReplyTo/Subject/GroupId/To/ContentEncoding/ReplyToGroupId/UserId/GroupSequence/CreationTime/AbsoluteExpiryTime/Durablefrom headers (header wins over option).CorrelationIdwas read by a bare name that didn't match the consumer'sredbAmqp.CorrelationId, breaking round-trip — now aligned. Standard AMQP 1.0 properties use bare names (transport metadata staysredbAmqp.*); the consumer additionally stampsContentEncoding/To/ReplyToGroupId/UserId. - Azure Service Bus — batch send now sets message properties.
SendBatchAsynccreated bareServiceBusMessages with no native/application properties; it now applies the same property mapping as single send (sharedApplyProperties), with a uniqueMessageIdper batched message. - Redis — stream-field header prefixed. The XADD field map was read by a bare
"StreamFields"key, inconsistent with every other Redis header; now the prefixedRedisHeaders.StreamFieldsconstant (bare name still accepted for back-compat).
Changed
- IBM MQ — MQMD header forwarding split into two tiers. The standard, JMS-equivalent MQMD fields
(
CorrelId/Priority/Expiry/Persistence/ReplyToQueue+ReplyToQueueManager) now forward from headers by default (header wins) — matching what a WMQ JMS client maps fromJMSCorrelationID/JMSPriority/JMSExpiration/JMSDeliveryMode/JMSReplyTo, so a naive consume→produce preserves them. The advanced/raw fields (MsgType/Format/GroupId/MsgSeqNumber) remain gated behindMqmdWriteEnabled(mirrors IBM MQ JMSWMQ_MQMD_WRITE_ENABLED) as they can alter message semantics.MqmdReadEnabledstaystrue.
Added
- Fluent DSL —
stringoverloads on expression-first connector builders. Value methods that accepted onlyIExpression(so a bare string literal wouldn't compile) now have additivestringoverloads across RabbitMQ, Sftp, Ftp, File, MqttNet, Kafka, Redis and Http (Ldap already had them). Each wraps the value inStringExpression, so.Host("localhost")works as a constant and.RoutingKey("order.${header.type}")still interpolates — full parity with the URI form. Purely additive; existingIExpressionmethods are unchanged.
redb.Route 3.3.0
Added
redb.Route.Llm— keyword knowledge retrieval (IKnowledgeStore.SearchTextAsync). A case-insensitive substring search over stored chunks, alongside the existing embedding path (SearchAsync) — for corpora without embeddings or callers with no query vector (e.g. a small structured rule-set where exact terms beat semantic similarity). Added as a default interface member (source-compatible for external implementers).RedbKnowledgeStorepushes a server-sideLIKEonto the indexed_objects.notecolumn (Contains(.., OrdinalIgnoreCase)→ComparisonOperator.ContainsIgnoreCase), returns only the matched rows, and ranks that small set in-process by occurrence count (dropping rows that matched only the metadata envelope);InMemoryKnowledgeStoredoes the same over its dictionary.redb.Route.Llm— prebuiltknowledge_searchtool (KnowledgeSearchTool). A ready.AsLlmToolroute overIKnowledgeStore.SearchTextAsyncso an agent can query the knowledge base itself: input{query, top_k?, collection?}→{results:[{id, collection, score, text}]}. The store is taken fromKnowledgeSearchOptions.Storeor resolved from the exchange'sIServiceProvider(picks upAddRedbLlmStorage()'s store).Collectioncan be pinned so the model'scollectionargument is ignored — scoping an agent to a single tenant / document set. Lives in the main package (notredb.Route.Llm.Tools) because it depends on the engine'sIKnowledgeStore; the utility-tools package stays Abstractions-only. WhenKnowledgeSearchOptions.EmbeddingProvideris set the tool runs semantic search (embeds the query → cosineSearchAsync); otherwise keyword (SearchTextAsync).redb.Route.Llm— embeddings transport (IEmbeddingProvider+OpenAiEmbeddingProvider). The retrieval half of RAG: turns text into vectors so the already-shippedKnowledgeChunkProps+IKnowledgeStore.SearchAsync(cosine) become usable. One OpenAI-compatible client (POST{baseUrl}/embeddings) over the sameLlmConnectionFactoryasOpenAiProvider— setModelIdto the embedding model (e.g.text-embedding-3-small) and it talks to OpenAI, Mistral, Together, DeepSeek, Gemini-compat, vLLM, llama.cpp, Ollama, LM Studio, … Preserves input order via the provider-reportedindex,EmbedOneAsyncconvenience for a single text.redb.Route.Llm—knowledge://ingest scheme (KnowledgeComponent). Producer-only:To("knowledge://<collection>?chunkChars=1000&overlap=100&embed=true")takes the exchange body as a document, chunks it (deterministic character windows with overlap), optionally embeds each chunk when anIEmbeddingProvideris registered, and upserts into theIKnowledgeStore. Chunk ids are{docId}#{index}(docId from?docId=or theknowledge.doc.idheader) so a re-ingest of the same document replaces its chunks in place. Turns document loading into a route:From("file://docs?include=*.md").To("knowledge://handbook").redb.Route.Llm—.Knowledge(collection, k)retrieval DSL. A route step that retrieves the top-K chunks for the current message and injects them into the system prompt (LlmHeaders.SystemPrompt), so a following.To("llm://…")answers grounded on them —From("kafka://questions").Knowledge("handbook", k: 5).To("llm://claude"). Semantic when anIEmbeddingProvideris available (embed query →SearchAsync), else keyword (SearchTextAsync); augments any existing system prompt; a no-op when no store is wired or nothing is retrieved (never breaks the pipeline). This completes the connector's RAG loop:knowledge://ingest → embeddings → keyword/semantic search →knowledge_searchtool /.Knowledge()injection.redb.Route.Llm—embed://scheme (EmbedComponent). Embedding as a first-class route step, symmetric withllm://:To("embed://<factory>")turns the exchange body (a text →float[], or a collection of texts →float[][], order preserved) into vectors onOut.Body. The URI host names anLlmConnectionFactory(setModelIdto the embedding model), so different routes pick different embedding models by name —From("kafka://texts").To("embed://openai").To("vector://sink").EmbedComponent.ProviderFactoryis overridable (defaults toOpenAiEmbeddingProvider.Create).- New connector:
redb.Route.Sqs— Amazon SQS + SNS (native AWS SDK for .NET v4). One package, two schemes:sqs://(queue consumer + producer) andsns://(topic publisher + SNS→SQS fan-out). The SQS consumer does long-polling,ConcurrentConsumers(N)competing loops, visibility timeout with an optional extend-while-processing heartbeat, at-least-once acknowledgement (delete on success), transacted ack via.Transacted(), and FIFO. The producer does single + batch send and FIFO group/dedup ids; the SNS publisher supports subject / message structure / FIFO and an SNS→SQS auto-subscription. LocalStack / ElasticMQ compatible viaserviceUrl=, full AWS credential chain, and W3C trace-context propagation across the hop. Seeredb.Route.Sqs/README.md. - New connector:
redb.Route.Telegram— Telegram Bot API (built onTelegram.Bot). Schemetelegram://: a long-polling consumer (receive, singlegetUpdatesstream per token, one bot client shared per token) and a producer withsend/document/photo/edit/delete/answermodes. Handles the 429retry_afterrate-limit contract (waits and retries), validatesparseMode(throws on a typo instead of silently sending raw markup), supports inline / reply keyboards (WithInlineKeyboard/WithReplyKeyboard), a webhook-unpack pipeline (UnpackTelegramUpdate), and a fluent DSL (Tg.Receive/Send/Document/...). Delivery is at-most-once (Telegram advances the update offset on dispatch — documented; no redelivery); parallel processing via the.Threads(N)EIP; consumer telemetry + producer spans. Seeredb.Route.Telegram/README.md. controller.Redb()— extension onRedbControllerthat resolves the per-request scopedIRedbService(its own connection) for the controller's current exchange. Use instead ofContext.GetRedbService(), which returns the shared captive singleton..Threads(N)concurrency EIP (coreredb.Route). A Camel-style processing-concurrency stage:From(...).Threads(N)…EndThreads()caps a route section's concurrency at N, so a strictly serial source (poll consumers, MQTT, a single request thread) can process up to N exchanges at once — the general-purpose alternative to.To("seda://x").ConcurrentConsumers(N)without a named endpoint. Adaptive by exchange pattern: InOnly is a fire-and-forget hand-off (clone + worker pool, a transaction boundary like.To("seda://")); InOut runs the body inline on the same exchange under aSemaphoreSlimgate, so the reply — onOutorIn— is preserved losslessly and request/reply (RPC) works across it (InOut is not a transaction boundary — the ambient transaction flows into the inline body). Options:.MaxQueueSize(n)and.EnqueueTimeout(TimeSpan)(default = wait for a free slot; on timeout throwsTimeoutException). Ordering not preserved at N > 1. Seeredb.Route/CONCURRENCY.md.
Fixed
redb.Route.Llm— non-ASCII text mangled on the wire (\uXXXXescaping). Several JSON serializers used the default encoder, which escapes every non-ASCII char. On tool results this ~6×'d the tokens the model saw for Cyrillic / CJK content (and could surface literalМ…); in the knowledge store it buried chunk text as\uXXXXin thenotecolumn, making the new keywordLIKEunable to match a raw non-ASCII query. Switched toJavaScriptEncoder.UnsafeRelaxedJsonEscapinginAgentEngine(tool-reply + error serializers),OpenAiProvider(request body — matchesAnthropicProvider, which already did this),McpProtocol(MCP JSON-RPC), and theRedbKnowledgeStorechunk envelope.redb.Route.Llm—RedbKnowledgeStore.UpsertManyAsync(bulk ingest) was non-functional. Two latent bugs on the never-exercised bulk path: (1) the existing-key lookupkeys.Contains(o.ValueString)over astring[]threwNotSupportedExceptionfrom the redb query parser (C# 13 binds it toMemoryExtensions.Contains); (2) a Props-hash "skip if unchanged" pre-check silently dropped every re-upsert of the property-lessKnowledgeChunkProps(empty Props → constant hash). Fixed the parser (seeredb.Corechangelog) and removed the pre-check so bulk re-ingest actually updates chunk text/embeddings.- DI-scope / connection leaks on the exchange lifecycle. Several paths created a per-exchange DI
scope (which owns a redb DB connection) that could escape without being disposed:
ThreadsProcessor,SedaProducerandVmProducerleaked the clone when the hand-off enqueue failed (cancellation / queue completed);WireTapProcessorleaked the tap clone when a useronPrepare/newBodycallback threw before dispatch; the scheduledllm://andexec://consumers never disposed their per-tick exchange (a scope leaked on every fire). All now dispose infinallyon every path. In addition,Exchange.ReleaseScopes()is now resilient to a throwingDisposeAsync: each cached scope is released in isolation (a fault is logged and the loop continues), so one broken scope can no longer strand its siblings — which would otherwise re-introduce the very connection leak this guards against. - Lazy producer start-up is now race-safe (no more cold-start
NullReferenceExceptionunder concurrency). On a cold start, concurrent exchanges reaching a not-yet-started producer could observe it half-initialised:ConnectableProducer.Start()set itsIsStartedflag beforeConnectAsync()completed, andToProcessorhanded out the producer as soon as it was created — beforeStart()finished. A second thread then calledProcess()on it (e.g. a Redis producer whose_dbwas still null → NRE). The bug was masked while sources were serial; it surfaced onceredb.Route.RabbitMQ3.2.2 madeConcurrentConsumers(N)truly parallel. Both paths are now single-flight — concurrent callers await the same startup, and a producer is observable as started only afterConnectAsync()has fully completed (its resources are ready). The dynamic-endpoint path (toD) was already safe viaLazy<Task<IProducer>>. - The default (unnamed)
IRedbServiceis now resolved per exchange, not as a shared singleton.ProcessWithRedb(...),SetBodyFromRedb(...),SetHeaderFromRedb(...)andBeginRedbTransaction()previously fell back to oneIRedbServicecaptured from the root DI provider — a single, non-thread-safe DB connection (EF-DbContext model) shared across every exchange. Under real concurrency (Splitter with parallel processing, SEDA,ConcurrentConsumers(N), concurrent HTTP requests) two exchanges drove that one connection at once and the driver threw "A command is already in progress" / "connection is busy". Each exchange now gets its own DI scope → its own scopedIRedbService→ its own pooled connection, cached on the exchange and disposed with it. The unscoped singleton is used only when there is no exchange (single-threaded seed).
redb.Route.Amqp 3.2.1 · redb.Route.IbmMq 3.2.1
Targeted hotfix in
redb.Route.Amqpandredb.Route.IbmMqonly — each bumps 3.2.0 → 3.2.1; every otherredb.Route.*package is unchanged (redb.Route.RabbitMQstays at 3.2.2,redb.Route.Kafkaat 3.2.1, the rest at 3.2.0). Both depend onredb.Route3.2.0 (no core change). Same class of bug as the RabbitMQ 3.2.2 fix:ConcurrentConsumers(N)was a no-op.Behaviour change to be aware of. Before this release, an AMQP or IBM MQ consumer processed messages strictly one at a time regardless of
ConcurrentConsumers— the option only sized an internalSemaphoreSlimthat a serial receive loop never let engage. NowConcurrentConsumers(N)runs N real competing consumers, so a route withN > 1processes up to N messages concurrently: per-destination ordering is no longer preserved on that route and its pipeline must be thread-safe. Routes at the default 1 stay strictly serial, exactly as before.
Fixed
redb.Route.Amqp — ConcurrentConsumers(N) did nothing (serial receive loop)
The AMQP consumer ran a single receive loop that awaited Process inline before receiving the next
message, so only one message was ever in flight; the SemaphoreSlim(ConcurrentConsumers) was
acquired and released by that same loop and never saw a second holder — a dead gate. Credit (link
prefetch) only buffered deliveries; it never produced concurrent processing.
The fix runs ConcurrentConsumers(N) as N independent competing consumers, each with its own
AMQP Session + ReceiverLink + serial loop (AMQPNetLite sessions/links are not thread-safe, so
concurrency comes from N independent workers, never from sharing one link). RPC replies are sent on
the worker's own session; the processed-count is updated atomically. A new
AmqpEndpoint.CreateDedicatedReceiverAsync creates a receiver on a fresh dedicated session.
Tuning note: for even load-balancing across competing consumers, keep Credit low (e.g. 1) —
a high credit lets one worker's prefetch buffer hoard the queue and drain it serially.
redb.Route.IbmMq — ConcurrentConsumers(N) did nothing (serial MQGET loop)
The IBM MQ consumer ran a single MQGET loop that awaited Process inline (Get → Process → Get),
so processing was strictly serial; the SemaphoreSlim(ConcurrentConsumers) was a dead gate (the count
was even wired correctly from options — unlike RabbitMQ where it was pinned to 1 — but the inline
await made it moot).
The fix runs ConcurrentConsumers(N) as N independent competing consumers, each with its own
dedicated MQQueueManager connection + destination handle + serial loop. This is required, not
cosmetic: the MQ managed client serialises MQI calls per connection and its transacted syncpoint is
connection-scoped (Backout() rolls back the whole connection), so each worker must own its
connection — which also makes transacted mode correct at N > 1 (per-worker commit/rollback). Queues
are opened INPUT_SHARED, i.e. true competing consumers. Topics are clamped to a single subscriber
with a warning — N managed non-durable subscriptions would each receive a copy of every message
(duplicate delivery), not share the load; use a queue destination for competing consumers. RPC reply,
backout-queue routing and the transacted ack all use the worker's own connection; the processed-count
is atomic. (The unrelated ~500 ms managed-client MQGET poll-tick latency — a future MQCB rewrite — is
untouched.)
Tests
redb.Route.Tests.Amqp— newAmqpConcurrencyTests:ConcurrentConsumers_ProcessesInParallel(5 workers,credit=1, observed max-concurrency 5),ConcurrentConsumersOne_ProcessesSerially(max-concurrency 1), andConcurrentConsumers_AllMessagesProcessedExactlyOnce(competing consumers share the queue — no duplication/loss). Full package suite: 133 passing against ActiveMQ Artemis.redb.Route.Tests.IbmMq— newIbmMqConcurrencyTests: the same three shapes onINPUT_SHAREDqueues (observed max-concurrency 5 atConcurrentConsumers(5); 1 at the default; 24/24 delivered exactly once). Full package suite: 163 passing against IBM MQ Developer Edition.
3.2.2
Targeted hotfix in
redb.Route.RabbitMQonly — this package bumps to 3.2.2; every otherredb.Route.*package is unchanged (redb.Route.Kafkastays at 3.2.1, the rest at 3.2.0). RabbitMQ 3.2.2 still depends onredb.Route3.2.0 (unchanged, already on NuGet) — no core changes, no mass-republish. Three items: (1) a fix for consumer dispatch concurrency, which was silently pinned to 1 soConcurrentConsumers(N)never actually parallelised a queue; (2) a fix for a channel leak on per-route Stop/Start; (3) a new framework-levelAutoAckconsumer option (broker-side auto-acknowledge / at-most-once), the RabbitMQ analogue of Kafka'sEnableAutoCommit.Behaviour change to be aware of. Before 3.2.2 every RabbitMQ consumer processed one message at a time regardless of
ConcurrentConsumers(the real gate — the AMQP consumer dispatch concurrency — was stuck at 1). With 3.2.2 a route that setsConcurrentConsumers(N > 1)now genuinely processes up to N messages concurrently, so per-queue message ordering is no longer preserved on such routes and their processors must be thread/concurrency-safe. Routes that leaveConcurrentConsumersat its default of 1 are unaffected — they stay strictly serial, exactly as before.
Added
redb.Route.RabbitMQ — framework-level AutoAck consumer option
The RabbitMQ consumer gains a typed AutoAck option (default false), bound from the URI
(?autoAck=true|false) like every other endpoint option, plus a matching fluent
RabbitBuilder.AutoAck(bool) method.
When enabled, the consumer subscribes with autoAck: true, so the broker settles every
delivery on hand-off (at-most-once): there is no manual BasicAck/BasicNack, and a failure
in the processor does not requeue the message. This is the mirror of the manual-ack default
(at-least-once: ack after a successful turn, nack-requeue on failure) and the RabbitMQ analogue
of the Kafka EnableAutoCommit option shipped in 3.2.1 — it removes the need for a SEDA
hand-off stage when a route wants fire-and-forget "ack on receive" semantics (e.g. WSO2-style
autoAck=true).
AutoAck cannot be combined with Transacted — an auto-acked delivery is settled by the
broker on hand-off and cannot be transactionally committed or rolled back — so
RabbitMQEndpointOptions.Validate() now rejects that combination.
Fixed
redb.Route.RabbitMQ — consumer dispatch concurrency was pinned to 1 (ConcurrentConsumers had no effect)
RabbitMQEndpoint.CreateChannelAsync built its channel with the three-argument
CreateChannelOptions(...) constructor, leaving the fourth parameter
(consumerDispatchConcurrency) at its compile-time default. In RabbitMQ.Client 7.2.1 that
default is Constants.DefaultConsumerDispatchConcurrency = 1, not null — verified against
the shipped assembly. A non-null per-channel value overrides the connection-level setting, so
every channel the library created was clamped to serial dispatch, and the value set on the
ConnectionFactory / URI (consumerDispatchConcurrency=N) was silently discarded — in both
named-factory and inline-connection modes.
The upshot: a single AsyncEventingBasicConsumer on a single channel received deliveries strictly
one at a time (inFlight = 1), and ConcurrentConsumers(N) — which only ever sized an internal
SemaphoreSlim — could not deliver any parallelism because the dispatcher never handed the
consumer more than one message at once. On production this showed up as a consumer that never kept
up: unacked climbed to the prefetch limit while messages were still processed serially.
The fix makes ConcurrentConsumers the single knob for consumer-side parallelism:
CreateChannelAsync now always passes consumerDispatchConcurrency explicitly, and the consumer
opens its consume channel with the dispatch concurrency set to ConcurrentConsumers (which also
sizes the semaphore). ConcurrentConsumers(N) therefore now processes up to N messages in
parallel. ConsumerDispatchConcurrency remains available as the connection-level default for other
channels and is no longer clobbered. A live-broker regression test
(Consumer_ConcurrentConsumers_ProcessesInParallel) publishes 20 messages, runs with
ConcurrentConsumers(5), and asserts the observed maximum concurrency exceeds 1; a companion test
asserts ConcurrentConsumers(1) stays strictly serial.
redb.Route.RabbitMQ — AMQP channel leak on per-route Stop/Start
RabbitMQConsumer.Stop() cancelled its subscription (BasicCancelAsync), drained in-flight work,
and closed its dedicated RPC reply channel — but left its main consume channel open and still
registered in the endpoint's channel list. Closing that list is done only by
RabbitMQEndpoint.Stop(), which the engine invokes only on full context teardown, never on a
per-route StopRoute. Since StartRoute reuses the same consumer instance and opens a fresh
channel, each Stop/Start cycle of an individual route leaked one channel — a cancelled, idle
channel (no deliveries, unacked = 0, but still open with the prefetch showing) that accumulated on
the pooled connection until the whole context was disposed (tpkg hot-reload / container restart).
The consumer now owns and releases its channel: Stop() closes, disposes, and unregisters the
consume channel via a new RabbitMQEndpoint.ReleaseChannelAsync, and the Start-failure cleanup path
uses the same method (so a partial start no longer leaves a stale handle in the list either). Double
release is safe — endpoint.Stop() still guards on IChannel.IsOpen, and the list removal is
idempotent. This is a RabbitMQ-package-local fix; the core RouteContext.StopRoute is unchanged.
A regression test (Consumer_StopStartCycles_DoNotLeakChannels) runs five Stop/Start cycles on a
live broker and asserts the endpoint's tracked-channel count returns to 0 after every Stop.
Tests
redb.Route.Tests.RabbitMQ— new live-broker suiteRabbitMQConcurrencyLeakAutoAckTests:Consumer_ConcurrentConsumers_ProcessesInParallelandConsumer_ConcurrentConsumersOne_ProcessesSerially(dispatch concurrency),Consumer_StopStartCycles_DoNotLeakChannels(channel leak), andAutoAck_DeliversMessage/AutoAck_ProcessorThrows_MessageNotRequeued/ManualAck_ProcessorThrows_MessageRequeued(AutoAck vs manual-ack requeue). Plus unit tests for theAutoAckbuilder param, its default-off, and theAutoAck+Transactedvalidation guard. Full package suite: 112 passing against RabbitMQ 4.x.
3.2.1
The code changes in this release land in
redb.Route.Kafkaandredb.Route.RabbitMQ— and only these two packages are bumped to 3.2.1. Every otherredb.Route.*package stays at 3.2.0 (no changes); a targeted hotfix, not a mass-republish. Kafka/RabbitMQ 3.2.1 depend onredb.Route3.2.0. Three items: (1) a new framework-levelEnableAutoCommitoption on the Kafka consumer (defaulttrue) that brings Kafka offset-settle into parity with the RabbitMQ consumer's post-process ack; (2) a fix for a doubleBasicAckin the RabbitMQ consumer when a route-level.Transacted()wraps a non-transacted consumer; (3) a fix for the Kafka transacted producer, which threwLocal: Erroneous stateon the deferred send.Behaviour change to be aware of. Before 3.2.1 a Kafka consumer route committed its offset only at a transactional boundary (
.Transacted()/.CommitTransaction()); a plainFrom("kafka:...")processed messages but never advanced the committed offset (the offset only moved on a graceful stop via the partitions-revoked commit). With 3.2.1 the defaultEnableAutoCommit=truecommits the offset inline after a successful turn, so a plain consumer settles at-least-once exactly like the RabbitMQ consumer already did. Set?enableAutoCommit=falseto restore the old "commit only at a transaction boundary" behaviour. Inside a transactional route the option is effectively ignored — the transaction owns the commit (see below).
Added
redb.Route.Kafka — framework-level EnableAutoCommit consumer option
The Kafka consumer gains a typed EnableAutoCommit option (default true),
bound from the URI (?enableAutoCommit=true|false) like every other endpoint
option, plus a matching fluent KafkaBuilder.EnableAutoCommit(bool) method.
This is a framework-level setting, deliberately not librdkafka's own
enable.auto.commit: the underlying client stays at manual commit
(EnableAutoCommit = false in the built ConsumerConfig) always, so the
library never commits un-processed offsets on a background timer. Instead the
redb.Route consumer commits after it knows the turn succeeded:
- After a successful
Processor.Process(...), the consumer commits the offset inline (IConsumer.Commit) — mirroring the RabbitMQ consumer'sBasicAck-after-Process. Single-message mode commits that message's offset; batch mode commits the last offset of the batch. - A transactional route takes precedence.
KafkaCommitActionnow carries aCommittedflag (idempotentInterlockedguard). When a.Transacted()/.CommitTransaction()boundary commits the deferredKafkaCommitActionduring the turn,Committedis already set by the timeProcessreturns, so the consumer skips the inline commit. The transaction owns the offset andEnableAutoCommitis effectively ignored — it only matters on non-transactional routes. - On a failed turn (
Processthrows) the inline commit is skipped and the message is re-delivered — at-least-once, unchanged.
Net effect: Kafka and RabbitMQ consumers now share the same default mental model
("settle after a successful turn"). The previous behaviour — where a plain Kafka
consumer silently never advanced its committed offset — is opt-out via
?enableAutoCommit=false.
Fixed
redb.Route.Kafka — transacted=true producer threw Local: Erroneous state on the deferred send
A Kafka producer with transacted=true set config.TransactionalId and called
_producer.InitTransactions(30s) on connect. That puts librdkafka into
transactional mode, where every Produce must be wrapped in
BeginTransaction … CommitTransaction. The connector never calls
BeginTransaction / CommitTransaction / AbortTransaction /
SendOffsetsToTransaction, so the deferred ProduceAsync — run when a
.Transacted() / .CommitTransaction() boundary commits the KafkaSendAction —
threw:
Confluent.Kafka.ProduceException: Local: Erroneous state
Confirmed end-to-end against a live 3-node KRaft cluster. The earlier tests missed it because they only asserted the deferred action was registered, never committed.
The fix drops transactional.id + InitTransactions from the transacted
producer path, leaving EnableIdempotence = true + Acks = All. The deferred
send is now a plain idempotent Produce deferred to the route boundary — which
delivers, and matches the connector's documented intent ("idempotent producer +
deferred commit, not EOS"). Real Kafka exactly-once
(BeginTransaction / SendOffsetsToTransaction / CommitTransaction) is scoped
in docs/KAFKA_TRANSACTIONS_TODO.md. A regression test
(TransactedProducer_DeferredCommit_Delivers) commits the deferred action and
asserts the message is actually delivered.
redb.Route.RabbitMQ — double BasicAck when a route .Transacted() wraps a non-transacted consumer
When a route-level .Transacted() segment wrapped a RabbitMQ consumer whose
endpoint was not ?transacted=true, the delivery was settled twice:
- The
TransactedProcessorcommitted the deferredRabbitMQAckActionat the.Transacted()boundary →BasicAck#1. - The consumer's own post-process branch (
if (!_options.Transacted)) then issuedBasicAck#2 on the same delivery tag.
RabbitMQ rejects the duplicate settle with
PRECONDITION_FAILED — unknown delivery tag and tears down the whole
channel, so every subsequent message on that channel is silently dropped. The
error path carried the symmetric double-BasicNack hazard.
RabbitMQAckAction now carries a Settled flag (idempotent Interlocked guard
on both Commit and Rollback — first settle wins). The consumer routes its
inline ack/nack through the same RabbitMQAckAction and skips it when
Settled is already set, so the broker sees exactly one settle per delivery. The
guard covers the success path and both the inner and outer catch blocks.
Tests
redb.Route.Tests.Kafka—Consumer_AutoCommitDefault_CommitsOffsetInline_BeforeStopandConsumer_AutoCommitDisabled_NoTransaction_DoesNotCommitInline. Both inspect the committed offset at the group coordinator (via a non-subscribing probe consumer, so it never joins the group / triggers a rebalance) while the consumer is still running — i.e. before any graceful stop, so the partitions-revoked commit cannot mask the result. The first asserts the offset is committed inline with the default option; the second asserts it stays uncommitted with?enableAutoCommit=falseand no transaction.redb.Route.Tests.RabbitMQ—Consumer_RouteTransactionAcksDuringProcess_NoDoubleAck. A processor commitsTRANSACT_ACTIONmid-Process(simulating the.Transacted()boundary), and the test asserts both published messages are processed — proving the channel survived the first ack instead of being torn down by a double settle.
3.2.0
All
redb.Route.*packages are versioned together at 3.2.0; the code changes in this release land inredb.Route,redb.Route.Llm,redb.Route.Llm.Tools,redb.Route.Llm.Mcp,redb.Route.Http,redb.Route.WebSocketandredb.Route.Exec(the other connectors are version-aligned, no code changes). The release bundles four areas of work: (1) end-to-end token-by-token streaming on the wire (IAsyncEnumerable<string>response bodies → SSE / chunked text over HTTP, one text frame per token over WebSocket); (2) REDB-backed stores for the remaining state surfaces (IBatchStore,IEvalRunStore,IKnowledgeStore,IPromptTemplateRegistry,IToolCacheStore); (3) async-batch callback plumbing (LlmCallbackProcessor+ newllm.batch.*headers); (4) a thin DSL/tool split across the homeless tools inredb.Route.Llm.Tools. Plus a named-redb-per-exchange hint (?redb=<name>), the newredb.Route.Llm.McpMCP-client connector that brings the community ecosystem of Model Context Protocol servers into the agent toolset, and two targeted bug fixes (LLM agent loop orphan tool_use recovery, Exec OEM codepage on Windows). No public API was removed or renamed. The store-interface additions are optional parameters with defaults — existing implementations and call sites compile unchanged.
Added
redb.Route — ThrottleProcessor / KeyedThrottleProcessor RFC 6585 §4 rejection mode
Both throttle processors gain an opt-in rejectOnOverflow constructor flag
(default false for backward compatibility) plus a matching fluent
.RejectOnOverflow() method on ThrottleDefinition and
KeyedThrottleDefinition. When set, overflow exchanges are short-circuited
with HTTP 429 Too Many Requests + a Retry-After header carrying
the current rate-limit period in delta-seconds (RFC 7231 §7.1.3), and a
small structured JSON body:
{
"error": "rate_limit_exceeded",
"error_description": "Rate limit exceeded. Retry after 1 second(s).",
"retry_after": 1
}
The behaviour is now selectable per-route in the DSL:
// Legacy: silent semaphore-wait until a slot frees (still the default).
.Throttle(maxPerPeriod: 10)
// RFC 6585 — fast-fail with 429 + Retry-After. Recommended for HTTP-facing
// routes; the silent-wait variant looks like a hung server to clients.
.Throttle(maxPerPeriod: 10).RejectOnOverflow()
// Same option on the per-key (per-IP / per-client_id) variant.
.Throttle(e => e.GetClientId(), maxPerPeriod: 10, period: TimeSpan.FromSeconds(1))
.RejectOnOverflow()
The non-blocking SemaphoreSlim.Wait(0) probe replaces the unconditional
await WaitAsync(ct) so a slot is only acquired when one is actually
available — the legacy mode still falls back to WaitAsync to preserve
the exact old timing for callers that didn't opt in.
Implementation lives in two places: the per-processor flag, and a shared
ThrottleRejection.Apply(exchange, period) helper that writes the 429
response (redbHttp.ResponseCode, Retry-After, JSON body) and calls
exchange.Stop() so no downstream processor (WireTap, tx commit,
idempotency capture) runs against the rejected exchange. Apache Camel's
Throttler has the same axis (rejectExecution=true/false) — this
brings the redb.Route EIP into parity.
The default stays false so every existing route continues to behave
exactly as it did in 3.1.0 and earlier; opting in is a per-route choice.
redb.Route.Llm / redb.Route.Http / redb.Route.WebSocket — end-to-end streaming wire contract for LLM token deltas
LlmProducer.ProcessStreamingAsync already emits an IAsyncEnumerable<string>
of provider token deltas into exchange.Out.Body when ?stream=true (or the
llm.streaming header) is set on the LLM endpoint. As of 3.1.0 only the
producer surface existed; downstream transports buffered the enumerable into
a single response. 3.1.1 wires the contract end-to-end so a route like
From("http://+:8080/chat")
.To("llm://claude?stream=true")
// Out.Body is IAsyncEnumerable<string> here
// HttpConsumer flushes each yield as one SSE 'data:' frame
streams token-by-token to the browser, and the equivalent WebSocket route
From("ws://+:9001/chat")
.To("llm://claude?stream=true")
// Each yield → one WebSocketMessageType.Text frame, endOfMessage=true
streams token-by-token to the WebSocket client. No new types, no new options
— transports inspect Out.Body and pick the right wire shape.
Producer-side contract — LlmProducer now sets two response markers
alongside the streaming body:
Out.ContentType ??= "text/event-stream"when not already set, so the HTTP transport defaults to SSE framing.Out.Headers[LlmHeaders.Streaming] = true("llm.streaming") — a stable signal any downstream component can branch on. Visible inWireTap/Multicast/ audit routes.
Late-bound summary headers (llm.tokens.in, llm.tokens.out,
llm.stop_reason, llm.tool.iterations) are written after the
IAsyncEnumerable completes; llm.provider.id and llm.model.id are
written up-front. Transports collect them post-enumeration and surface them
in a transport-appropriate way (see HTTP event: done trailer below).
Scope. The streaming path calls
ILlmProvider.StreamAsyncdirectly and bypassesAgentEngine— so tools (?tools=) are not dispatched,AddRedbLlmStorage()stores are not invoked, and governance hooks do not fire on a streamed turn. Use streaming for user-facing rendering of a single assistant turn; keep the non-streaming path when you need tools, persistence, approvals or budgets.
HttpConsumer (redb.Route.Http). Detects Out.Body is IAsyncEnumerable<string> and picks one of two writers based on
Out.ContentType:
text/event-stream→ SSE: per-linedata:prefix, blank-line terminator per yield, response flushed per chunk. The stream ends withevent: done\ndata: {…json…}\n\nwhose payload is built opportunistically from whicheverllm.*summary headers are present on the message at end-of-stream (llm.tokens.in,llm.tokens.out,llm.cost.usd,llm.stop_reason,llm.tool.iterations,llm.model.id,llm.provider.id) — missing headers are omitted from the JSON, custom ones (e.g. a pricing-table-derivedllm.cost.usd) ride along for free.- anything else → chunked plain text: one yield = one chunk on the Transfer-Encoding stream, no SSE framing, no trailer.
Both writers set the standard "do-not-buffer-me" envelope:
Cache-Control: no-cache, no-transform, X-Accel-Buffering: no, and
IHttpResponseBodyFeature.DisableBuffering(). This neutralises nginx and
similar reverse-proxy buffers and is what makes SSE actually progressive on
the wire (without X-Accel-Buffering: no nginx by default holds the whole
response until the upstream closes). Empty / null chunks are skipped — LLM
providers periodically emit empty SSE keep-alives that must not turn into
empty wire chunks. Client cancellation (HttpClient aborts the request)
propagates into the server-side await foreach via HttpContext.RequestAborted,
so the upstream provider stream is torn down promptly — no pinned upstream
sockets.
WsConsumer (redb.Route.WebSocket). Detects the same body type in the
InOut branch of HandleWebSocket and yields one
WebSocketMessageType.Text frame per yield with endOfMessage=true. Order
is preserved (the per-connection receive loop awaits each SendAsync before
reading the next inbound frame, so writes are naturally serial per socket);
empty chunks are skipped. The cancellation token is the consumer's
drain-safe _drain.ProcessingToken, so an in-flight stream completes during
a graceful stop. The non-streaming ResolveResponseBody path is unchanged
for non-IAsyncEnumerable bodies.
Tests. Two transport-level test suites pin the wire contract without needing any LLM provider:
redb.Route.Tests.Http/HttpStreamingTests—Sse_PerChunkFlush_AndDoneTrailer,ChunkedPlain_NoSseFraming_NoTrailer,ChunksArriveProgressively_NotBuffered, andClientCancel_PropagatesToEnumerator. Verifies SSE line framing, theevent: doneJSON payload, progressive arrival (first byte well before last yield), and that aborting theHttpClientrequest surfaces on the server-side enumerator within seconds.redb.Route.Tests.WebSocket/WsStreamingTests—Streaming_OneFramePerYield_OrderPreservedandStreaming_EmptyChunksSkipped. Verifies one-frame-per-yield, ordered delivery, and that null / empty yields do not produce wire frames.
A new env-gated suite — redb.Route.Tests.Llm/LiveStreamingTests — exercises
ILlmProvider.StreamAsync end-to-end against real free-tier providers
(Anthropic Claude Haiku 4.5 via AnthropicProvider native SSE, plus Groq /
Cerebras / Gemini / Mistral / OpenRouter via OpenAiProvider). Each test
asserts more-than-one chunk on the wire (proves real streaming), at least one
text delta, the expected substring in the accumulated answer, and a non-null
terminal StopReason. Auto-skips when the corresponding key env var is
missing, same as LiveProviderTests.
redb.Route.Llm — REDB-backed stores for the remaining state surfaces
The agent loop ships in-memory defaults for every governance surface; 3.1.0
shipped REDB-backed Conversation, Approval, CostBudget,
ToolIdempotency and AuditObserver stores. 3.1.1 lands the rest:
RedbBatchStore(IBatchStore) — tracks async-batch jobs submitted to Anthropic Message Batches / OpenAI Batch / vLLM batch endpoints; the callback webhook correlates back to the originating conversation through this store. Backed by the newLlmBatchPropsschema.RedbEvalRunStore(IEvalRunStore) — persists evaluation runs by scenario / fingerprint for leaderboard queries.RedbKnowledgeStore(IKnowledgeStore) — RAG retrieval over theKnowledgeChunkPropsschema.RedbPromptTemplateRegistry(IPromptTemplateRegistry) — versioned prompt store (the previous default was in-memory only).RedbToolResultCache(IToolCacheStore) — deterministic-tool result cache with TTL.
All five are opt-in through the same AddRedbLlmStorage() extension
(ServiceCollectionExtensions grew the appropriate TryAddSingleton
wiring) and ride on the existing IRedbService resolution path. A new
ToolIdempotencyProps schema replaces the ad-hoc storage shape used in
3.1.0 — see Changed below.
redb.Route.Llm — async-batch callback plumbing
LlmCallbackProcessor— a vanillaIProcessorthat consumes inbound webhook callbacks from async-batch LLM APIs. Wired into any HTTP route (no new URI scheme): resolves the batch id from header / query / JSON body, deduplicates viaIToolIdempotencyStore(keyed"batch:<id>"), populates conversation / provider / model headers from the original submission stored inIBatchStore, and marks the batch completed. A duplicate callback setsLlmHeaders.BatchDuplicate=trueso a downstreamChoice().When(...).Stop()can drop it cleanly.- New
LlmHeadersconstants:BatchId(llm.batch.id),BatchStatus(llm.batch.status),BatchDuplicate(llm.batch.duplicate),ConversationMessageId(llm.conversation.message.id).
redb.Route.Llm — named-redb hint per exchange (?redb=<name>)
The LLM connector now lets a route pin which named IRedbService instance
its persistence stores write to. Useful when one Tsak host runs multiple
LLM products against different DBs.
- New URI option
?redb=<name>parsed intoLlmEndpointOptions.Redb. - New property key
LlmKeys.RedbName(llm.redb.name) stamped ontoIExchange.PropertiesbyLlmProducer; storage implementations resolve the redb instance viaIRouteContext.GetRedbService(name, exchange). - Every
I*Storemethod gained an optionalIExchange? exchange = nullparameter so REDB-backed implementations can read this hint without changing call sites — in-memory implementations ignore it. Source- compatible: every interface change is an optional parameter with a default; existing implementations and call sites compile unchanged. - Default
unnamedIRedbServicefrom the route context is used when no hint is set, matching the 3.1.0 behaviour exactly.
redb.Route.Llm.Tools — DSL / tool split
Each homeless tool was reshaped into a thin IProcessor (*Tool.cs) plus
a fluent route-DSL extension (*Dsl.cs) that mounts the processor with
typed options. Affects HttpFetchTool, JsonPathTool, MathEvalTool,
RegexExtractTool, TavilyWebSearchTool, XPathTool. The user-visible
DSL shape is:
From("direct:fetch-weather")
.AsLlmTool("get_weather")
.Description("Fetches weather for a URL.")
.Input("""{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}""")
.Then()
.HttpFetch(new HttpFetchOptions { HostAllowlist = ["api.weather.gov"] });
A new shared helper LlmToolJson centralises the small JSON-payload
parsing / writing that every tool was duplicating. The split keeps the
agent-engine surface unchanged — tool descriptors and registry stay the
same; only the way you wire a tool into a route moves to a one-line DSL
call.
redb.Route.Llm — small additions
LlmMetricsexposes one more counter for stream chunks alongside the existing call / iteration / token meters.LlmConsumerhonours the same?redb=hint when scheduling aFrom("llm://...")agent run, so scheduled agents persist into the same named DB as inbound producer calls.Engine/PromptRef,Engine/Eval/LlmEvalRunnerupdated for the new store signatures.
redb.Route.Llm — xAI Grok provider alias
OpenAiProvider.ResolveDefaultBaseUrl gains a "grok" / "xai" alias
that resolves to https://api.x.ai/v1/. No other changes: tool calls,
streaming, budget enforcement and conversation memory work identically to
every other OpenAI-compatible provider.
new LlmConnectionFactory("grok")
{
Provider = "grok",
ModelId = "grok-3-mini",
ApiKey = Environment.GetEnvironmentVariable("REDB_LLM_GROK_KEY")
}
LiveProviderTests extended with five Grok scenarios (Smoke / NonAscii /
ToolUse / Usage / StopReason), gated on REDB_LLM_GROK_KEY.
redb.Route.Llm — per-message audit fields on MessageProps (compliance / replay)
Every assistant turn now persists the full set of inputs the provider call was made under. This closes the audit gap that previously forced auditors to trust that "the system prompt and sampling settings were the same as the ones currently in config" — now they're stamped on the row that produced the answer.
MessageProps (and its mirror ConversationMessageMeta) gain seven
nullable columns:
| Field | Set on | Purpose |
|---|---|---|
Temperature, MaxTokens, TopP |
assistant rows | effective sampling values after merging request + factory defaults |
PromptTemplateName, PromptTemplateVersion |
every row in the run | FK pair into PromptTemplateProps — pins the exact prompt text |
ToolSetHash |
assistant rows | SHA-256 of the canonical (name + description + InputSchema) of the tool set exposed on this call; detects tool-surface drift across runs |
ProviderSystemFingerprint |
assistant rows | OpenAI's system_fingerprint (and any echoing OpenAI-compatible provider — xAI, Together); null on Anthropic / Gemini-compat / Ollama |
Wiring:
AgentRequestgainsPromptTemplateName+PromptTemplateVersion. Callers that resolve a managed prompt template viaIPromptTemplateRegistryset the pair so the engine can stamp it on every persisted message of the run.AgentEnginecomputesToolSetHashonce per run (canonical sort by name, rawInputSchemafolded in verbatim — schema string changes show up as hash drift, which is exactly the auditor signal) and pipes it alongside the effectiveTemperature/MaxTokens/TopPintoPersistMessageAsync.OpenAiProvider.CompleteAsyncreadssystem_fingerprintfrom the response root and surfaces it onLlmResponse.ProviderSystemFingerprint; the engine forwards it to the assistant message row.RedbConversationStorewrites the seven fields intoMessagePropson append and rehydrates them on load; nothing else in the persist / materialise path changes.
Because every new column is nullable on both MessageProps and
ConversationMessageMeta, existing rows and existing call sites compile
and load unchanged. No migrations required — REDB picks up the new
props automatically.
What this still cannot solve. Closed-source provider drift where the backend does not surface a fingerprint (Anthropic, most Gemini-compat endpoints): when the provider silently re-releases a model under the same id, no per-message capture on our side can detect it. For compliance-bound deployments the only honest answer remains self-hosted (
ollama,lmstudio, vLLM viahuggingface) — the alias surface for those is unchanged.
redb.Route.Llm — UserId + free-form AuditTags on every persisted row
The 3.1.1 audit-fields work above pinned the machine side of the call (model
id, prompt hash, tool-set hash, sampling settings). This follow-up extends the
same MessageProps row with the human / governance side — who issued
the call and under what business labels — so a single row answers the
auditor's full question without joining to anything external.
MessageProps (and the mirror ConversationMessageMeta) gain two more
nullable columns:
| Field | Type | Purpose |
|---|---|---|
UserId |
string? |
Principal id stamped on every row of the run — pulled from the producer's ?user= URI option (literal or ${header.X} expression) or, falling back, the llm.user.id header. |
AuditTags |
Dictionary<string,string>? |
Free-form key → value audit labels stamped on every row. Sources merged at producer time: the ?audit=key=val,key=val URI CSV (each side URL-encoded so commas/equals in literal values are safe) ⊕ inbound llm.audit.<name> headers; headers win on collision so per-call dimensions can override per-route defaults. |
AuditTags is a real REDB Pro Dictionary<string,string> — not JSON. That
means the column is queryable through native LINQ-to-SQL (see
redb.Examples/E060_DictContainsKey, E061_DictIndexer,
E062_DictNestedClass):
// Pull every row that came from a specific tenant, server-side, no client scan:
var rows = await redb.Query<MessageProps>()
.Where(m => m.AuditTags!["tenant"] == "acme-prod"
&& m.UserId == "alice@acme.com")
.ToListAsync();
DSL surface — three new fluent methods on LlmBuilder:
.To(LlmDsl.Factory("haiku")
.User("${header.X-User-Id}") // principal — literal or ${header.X}
.Audit("tenant", "${header.X-Tenant}") // repeatable, dynamic
.Audit("env", "prod") // repeatable, literal
.PromptTemplate("triage", "v1") // (name, version) pinned per row
.AsUri())
Wiring (additive, no breaking changes — every new field is nullable on both DTOs and every public method keeps its existing signature):
LlmHeadersgainsUserId = "llm.user.id"andAuditTagPrefix = "llm.audit.".LlmEndpointOptionsgainsUser,Audit(CSV),PromptTemplateName,PromptTemplateVersion. Bound from URI by reflection like the rest of the options — no parser change.LlmProducerresolves${header.X}/${property.X}/ literal expressions pre-call against the inbound exchange, merges the?audit=CSV with anyllm.audit.<name>headers (header wins on collision), and pipes the resolved values throughAgentRequest.AgentEngine.PersistMessageAsyncreadsrequest.UserId/request.AuditTagsand stamps both onto everyConversationMessageMetait creates — same row cardinality (system / user / tool-result / assistant), no extra writes.RedbConversationStorewrites both fields on append and rehydrates them on load.Dictionary<string,string>materialises through the framework's native dict serialiser; no custom JSON path on either side.
Demo: see demos/Llm.AuditShell/ — single-file
HTTP shell that exposes both option-side defaults and header-side overrides,
plus the swap comment for RedbConversationStore and the LINQ-by-AuditTags
query above.
redb.Route.Llm — operator-side audit fields: factory alias, base URL, provider response id, latency, key fingerprint, retry count
Building on the UserId + AuditTags row above, this slice closes the
"which connection actually answered, and how long did it take?" gap on the
operator side — the dimensions a host already knows pre-call but had to
reconstruct from logs after the fact. Six more nullable columns on
MessageProps / ConversationMessageMeta:
| Field | Type | Stamped on | Source |
|---|---|---|---|
FactoryName |
string? |
every row | LlmConnectionFactory.Name (the operator-chosen profile alias, e.g. "haiku", "gpt-mini") |
BaseUrl |
string? |
every row | LlmConnectionFactory.BaseUrl (null → provider default endpoint was used) |
ProviderResponseId |
string? |
assistant rows | provider response top-level id (OpenAI / xAI / Together / Anthropic) |
LatencyMs |
long? |
assistant rows | wall-clock around ILlmProvider.CompleteAsync |
ApiKeyFingerprint |
string? |
every row | SHA-256 of LlmConnectionFactory.ApiKey, first 16 hex chars (non-secret) |
RetryCount |
int? |
every row | route-framework retry counter on the inbound exchange (see fallback chain below) |
Why this matters in audit: when a host registers multiple connections to the
same provider — different keys per tenant, separate quotas per environment,
old/new key during rotation — the (provider, model) pair is no longer enough
to answer "which configured connection served this row?". FactoryName +
BaseUrl + ApiKeyFingerprint make the answer one column lookup.
ProviderResponseId cross-references the provider's own usage / billing
logs; LatencyMs lets a compliance pull profile p99 per (tenant, model)
without touching application telemetry.
LINQ-to-SQL example — slow-tail rows for a specific configured connection:
var slowGoldRows = await redb.Query<MessageProps>()
.Where(m => m.FactoryName == "haiku"
&& m.AuditTags!["tier"] == "gold"
&& m.LatencyMs > 5000)
.OrderByDescending(m => m.LatencyMs)
.ToListAsync();
Wiring (additive, every field nullable on both DTOs):
LlmResponsegainsProviderResponseId. Both providers —OpenAiProviderandAnthropicProvider— parse the top-levelidfrom the response JSON.AgentEngine.PersistMessageAsyncaccepts two new optional parameters (providerResponseId,latencyMs). The assistant-persist site passeslast.ProviderResponseIdand the stopwatch elapsed around the provider call; non-assistant sites passnull, null.FactoryName/BaseUrl/ApiKeyFingerprintare computed insidePersistMessageAsyncfromrequest.Factoryso every row of the run carries them with no per-call-site plumbing change.ApiKeyFingerprintis computed viaSHA256.HashDataof the UTF-8 key bytes, thenConvert.ToHexString(hash[..8]).ToLowerInvariant()→ 16 hex chars. Empty / null key → null fingerprint. The key itself never reaches the conversation store.RetryCountis read once at the top ofRunAsyncfrom the inbound exchange and stamped identically on every row of the turn. Source order:exchange.Properties["RetryAttempt"](set byRetryProcessorfor the per-step.Retry(...)DSL), thenexchange.In.Headers["CamelRedeliveryCounter"](set byOnExceptionProcessor), thenexchange.In.Headers["CamelDeadLetterRedeliveryCount"](set byDeadLetterProcessor). Null when none are present (first / only delivery).RedbConversationStorewrites and rehydrates all six fields. No schema migration needed — REDB stores added props on existing rows transparently.
redb.Route.Llm.Mcp — new package — MCP-client connector for the agent toolset
redb.Route.Llm.Mcp is a producer-only NuGet that lets the agent consume
the community ecosystem of Model Context Protocol servers (filesystem,
git, fetch, github, sqlite, Serena, …) without writing a C# adapter per
server. The package adds the mcp:// URI scheme — mcp://serverName/toolName
invokes tools/call on the named MCP server with the exchange body as JSON
arguments — and wires a hosted service that, on host startup, spawns each
registered server, performs the initialize + tools/list handshake, and
projects every remote tool into the existing IToolDescriptorRegistry as
an McpToolDescriptor : ILlmToolDescriptor. The agent picks them up via
DI like any native tool.
Because every MCP tool becomes a regular LlmToolCapability, the existing
audit (ToolSetHash), governance (Safety overrides per (server, tool)
regex), observability (OnToolInvokedAsync) and approval pipeline apply
verbatim — no parallel code paths.
Transports. McpTransport.Stdio(command, args, env, workDir) spawns an
external process and exchanges newline-delimited UTF-8 JSON-RPC frames over
stdin/stdout (stdin writes serialised through a SemaphoreSlim, stderr
drained to the logger at trace, stdout pump skips non-JSON lines). The
encoding is BOM-less UTF-8 (UTF8Encoding(false)) — the static
Encoding.UTF8 emits a BOM on first WriteLine and many MCP servers
(Serena, Anthropic reference) reject the BOM-prefixed first frame as
invalid JSON. McpTransport.Http(baseUrl, apiKey) POSTs JSON-RPC to the
base URL and opens an SSE channel for server-initiated frames
(notifications/tools/list_changed triggers a registry rebuild).
Cancellation. IProducerTemplate.RequestBody(uri, body, ct) (the
CT-aware overload) threads the cancellation token through IProducer.Process
and into IMcpClient.CallToolAsync(ct). On cancel the client emits a
JSON-RPC notifications/cancelled for the pending request id and removes
the TCS so callers stop waiting.
Tool name budget. Provider tool-name caps (Anthropic / OpenAI) max at 64
chars. McpToolDescriptor.BuildModelFacingName(server, tool) sanitises both
parts to [a-zA-Z0-9_], truncates the server prefix to 24 chars and the
tool to 36, and joins with __ (e.g. serena__get_symbols_overview).
Duplicates after truncation are logged and skipped.
Wiring.
services.AddRedbRoute()
.AddRedbRouteLlm()
.AddRedbRouteMcp()
.AddMcpServer("serena", McpTransport.Stdio(
"uvx",
["--from", "git+https://github.com/oraios/serena",
"serena", "start-mcp-server",
"--context", "ide",
"--project", projectPath]));
The hosted service registers before RouteHostedService, so descriptors
are in the registry by the time routes compile.
Status / liveness. IMcpClient.Status exposes
Idle / Connecting / Healthy / Restarting / Dead; the producer
short-circuits with McpException when a registered client is Dead (no
silent hangs on a torn-down transport).
redb.Route.Llm — IProducerTemplate.RequestBody(uri, body, ct) CT-aware overload
IProducerTemplate gained a third overload that accepts a
CancellationToken. Existing call sites that use the two-argument form
compile unchanged (the no-CT overload remains as a ct: CancellationToken.None
shim). AgentEngine.DispatchToolEndpointAsync now threads its run-level CT
through to the producer, so an aborted agent iteration cancels the in-flight
tool RPC at the transport layer instead of waiting for it to finish before
unwinding.
Changed
redb.Route.LlmI*Store contracts. Every store interface inredb.Route.Llm/Engine/Storage/*(IApprovalStore,IConversationStore,ICostBudgetStore,IEvalRunStore,IKnowledgeStore,IPromptTemplateRegistry,IToolCacheStore,IToolIdempotencyStore) gained an optionalIExchange? exchange = nullparameter to thread the named-redb hint through. Source-compatible: optional with default, existing implementations / call sites compile unchanged.ToolIdempotencyPropsschema — the per-tool-call idempotency rows moved from the genericToolCachePropsshape to a dedicatedToolIdempotencyPropsschema with explicit lifecycle fields. The two surfaces previously shared one table; splitting them lets the cache TTL and the idempotency receipt evolve independently. No data migration shipped — early-3.1.x adopters runningAddRedbLlmStorage()against populated data should treat this as fresh state (the wider rollout happens with thePhase 2story, where stores get their migration helpers).
Fixed
redb.Route — ProducerTemplate.SendAsync / RequestBody auto-start the resolved producer
IProducerTemplate.SendAsync(endpointUri, …) resolved an endpoint via
Context.GetEndpoint(uri) → cached an IProducer via endpoint.CreateProducer() →
called producer.Process(exchange) directly. For DirectVm / Direct /
Seda producers this was fine because they don't extend ConnectableProducer,
but for every other transport (HttpProducer, KafkaProducer, AmqpProducer,
AzureServiceBusProducer, MqttNetProducer, RabbitMqProducer, RedisProducer,
SmtpProducer, LdapProducer, WmqProducer, …) EnsureStarted() threw
InvalidOperationException: <name> has not been started. Call Start() first.
because ConnectableProducer.Process requires Start() to flip the started
flag and call ConnectAsync first. The cached producer was created but
never started, so SendAsync was effectively broken for every connection-
based transport — direct-vm-only scenarios masked the gap.
ProducerTemplate.SendAsync(IEndpoint, IMessage),
ProducerTemplate.SendAsync(IEndpoint, object),
ProducerTemplate.RequestBody(IEndpoint, object, ct), and
ProducerTemplate.RequestBody(IEndpoint, IMessage, ct) now call
await producer.Start(ct).ConfigureAwait(false) between
GetOrCreateProducer and the first Process call. ConnectableProducer.Start
short-circuits via Interlocked.CompareExchange on the started flag, so the
extra call is a one-time setup per producer / process-lifetime and a no-op
on every subsequent send.
This is the seam that unblocked outbound HTTP webhook delivery in
redb.Identity (W1 / outbound webhook subscriptions) — the identity events
route hands the message to ProducerTemplate.SendAsync(subscription.Url, …)
and the URL scheme (https://…, kafka://…, amqp://…) resolves to the
right transport without the Identity codebase touching IHttpClientFactory
or any transport-specific surface.
redb.Route.Controllers — HttpControllerDispatcher.WriteResult clears Out.Body when the controller returns null
When an HTTP controller returns null (intended: no response body → 204
No Content), HttpControllerDispatcher.WriteResult initialised the
response via:
exchange.Out ??= exchange.In.Clone();
var defaultCode = result is null ? 204 : 200;
if (result is not null) { exchange.Out.Body = result; }
The Out ??= In.Clone() carried In.Body across. For HTTP DELETE / HEAD
requests with Content-Length: 0 In.Body is Array.Empty<byte>() —
non-null byte[]. With result is null the if-branch was skipped and
Out.Body stayed as that empty byte[]. Downstream the HTTP consumer
matched body is byte[] and called Response.Body.WriteAsync(...),
which on Kestrel hard-throws for 204 per RFC 7230 §3.3.3 / RFC 9112 §6.1
("Writing to the response body is invalid for responses with status
code 204" from HttpProtocol.FirstWriteAsyncInternal — fires even for
zero-length writes). Earlier pipeline side-effects (database mutations,
audit events) had already committed, so clients saw a torn TCP response
instead of a clean 204.
The dispatcher now explicitly nulls exchange.Out.Body in the result is null
branch. The request body is input; it must not echo into the response.
Symptom observed on SCIM DELETE /Users/{id} (RFC 7644 §3.6 mandates
204) but the bug is generic to any controller that signals 204 by
returning null.
redb.Route.Http — HttpConsumer.WriteResponse skips body write for 204 / 304 / 1xx
Defense-in-depth companion to the dispatcher fix above. RFC 7230 §3.3.3 /
RFC 9112 §6.1 require that 1xx, 204, and 304 responses MUST NOT contain a
message body, and Kestrel hard-throws on Response.Body.WriteAsync for
those status codes — even on zero-length writes. HttpConsumer.WriteResponse
called WriteAsync unconditionally when body is byte[] and tore the
TCP response if any upstream layer set a body for those statuses.
The consumer now resolves the response status code before reaching the
body-write branches and short-circuits with an intentional no-op when
the status is 204, 304, or any 1xx. The header copy above the body
block still propagates Location / ETag / Set-Cookie, which is the
only legitimate payload for these status families. Silently dropping a
non-empty body for these codes is safer than letting Kestrel kill the
response mid-flight — a producer with a bug to fix is a less acute
symptom than a torn TCP connection visible to clients.
redb.Route — parallel Splitter / Multicast branches isolate the ambient transaction per branch
When a parallel Splitter (.Split(...).Parallel()) or Multicast
(MulticastProcessor, parallel by default) runs inside a .Transacted(...)
segment, every branch was dispatched with Task.Run — which flows the caller's
ExecutionContext. Because the route's TransactionScope uses
TransactionScopeAsyncFlowOption.Enabled, all branches observed and could
concurrently enlist resources in the same Transaction.Current.
System.Transactions forbids concurrent use of a single transaction across
threads: a second concurrent enlistment of a resource that participates in the
ambient transaction (SQL / ADO.NET / redb DB work) either promotes to MSDTC or
throws "transaction context in use by another thread".
Each parallel branch now runs under its own
Transaction.Current.DependentClone(DependentCloneOption.BlockCommitUntilComplete)
(new internal DependentTransactionBranch helper): the dependent clone is a
private ambient transaction for that branch's thread, and the parent's commit
blocks until every branch signals completion, so a branch's writes are never
committed half-finished. No-op when there is no ambient transaction (the common
non-transacted path runs with zero overhead).
Broker transports that defer via Properties["TRANSACT_ACTION"]
(Kafka / RabbitMQ / Redis / Azure Service Bus / AMQP) were never affected:
they do not enlist in System.Transactions, and each registers under a
per-message-unique key (kafka-send-{guid}, rabbitmq-ack-{deliveryTag},
asb-ack-{sequence}, …), so concurrent fan-out branches to the same endpoint
accumulate distinct entries in the thread-safe dictionary and commit/roll back
atomically. The stale TransactedProcessor doc comment that claimed keys were
"typically the endpoint URI" has been corrected.
redb.Route — detached branches (WireTap, Debounce) no longer leak the caller's transaction/trace context
WireTapProcessor (fire-and-forget Task.Run) and DebounceProcessor's
quiet-period flush (Task.Delay(...).ContinueWith(...)) both dispatch
downstream work on a thread-pool continuation that, by default, inherits
the caller's ExecutionContext. Two ambient values flowed across that
boundary and were unsafe once the originating route call had unwound:
System.Transactions.Transaction.Current— routes wrap segments in aTransactionScopecreated withTransactionScopeAsyncFlowOption.Enabled(TransactionPolicy.CreateScope), so the ambient transaction flows. A detached branch starting after the scope completed/disposed still saw the leakedTransaction.Current; any producer/DB code that auto-enlists threw "the current TransactionScope is already complete". A WireTap or Debounce nested inside a.Transacted(...)segment is the reproducer.System.Diagnostics.Activity.Current— the originating span, likewise already stopped, so telemetry the branch emitted was parented to an ended span (a child whose start time post-dates its parent — a "tail" hanging off a request that already returned).
Both branches now route through a new internal DetachedDispatch helper.
Capture() snapshots the trace context + transaction presence on the
originating thread; Enter() runs at the top of the branch body and
(a) opens a TransactionScope(Suppress) so the branch runs with no
ambient transaction (only when one actually leaked — zero cost otherwise),
and (b) re-roots telemetry as a fresh root span linked (ActivityLink)
to the originating trace — correlation is preserved without the broken
parent lifecycle. Non-transaction AsyncLocal state (user/auth context) is
deliberately left flowing, since a detached audit branch usually needs it.
Additionally, WireTapProcessor now strips the deferred-transport-action
dictionary (Properties["TRANSACT_ACTION"]) from its clone: Exchange.Clone()
copies Properties shallowly, so the tap clone previously shared the same
ConcurrentDictionary that the owning TransactedProcessor commits/rolls
back, and a tap mutating it could race the main commit. The tap runs with the
transaction suppressed, so it has no part in that set.
ThrottleProcessor / KeyedThrottleProcessor were audited and are not
affected — their detached ContinueWith only releases a rate-limit semaphore
slot; the downstream Process is awaited inline. The concurrent-enlistment
behaviour of parallel Splitter / Multicast branches (all sharing one
ambient transaction under Task.WhenAll) is a distinct concern tracked
separately and intentionally out of scope here.
redb.Route.Http — concrete route paths now out-rank catch-all on the same (host, port)
SharedHttpServerManager matched routes in pure registration order and
returned the first whose template matched. A catch-all (/{**path})
therefore swallowed every route registered after it on the same listener —
a concrete path such as /api/echo could never win once a {**path}
dispatcher was already registered (acute when the catch-all auto-starts at
boot and the specific route registers later, e.g. an AutoStart(false)
route started by hand). ServerEntry.GetCompiled() now orders the match
table by specificity — literal-heavy templates first, route parameters
before more parameters, catch-all ({**…}) last — with registration order
kept as a stable tie-breaker so equal-specificity routes preserve their
previous first-registered-wins behaviour. Both MatchRoute and the
CORS-dispatch MatchByPath consume the ordered table, so a specific path
and a {**path} fallback can coexist on one port, matching ASP.NET-style
routing precedence.
redb.Route.Llm — orphan tool_use recovery on conversation load
AgentEngine.RunAsync now sanitises the loaded conversation path: if the
last persisted message is an assistant turn that has tool_use blocks
without a matching tool_result user turn after it (the previous run was
cancelled, timed out, or threw between persisting the assistant message
and dispatching the tool — see AgentEngine.cs lines 195/222), a
synthetic tool_result(error: "orphaned_tool_use_recovered") user message
is appended and persisted before the new user prompt is added.
Without this, any provider that strictly enforces tool_use/tool_result
pairing — notably Anthropic's Messages API
(400 invalid_request_error: tool_use ids were found without tool_result blocks immediately after) — 400's forever on every subsequent request,
poisoning the conversation permanently. RedeliveryPolicy then multiplies
the failure across retries.
The recovery is logged at warning level (Recovered {N} orphaned tool_use block(s) in conversation {Conv} on load.) so production occurrences are
visible. Applies uniformly to InMemoryConversationStore and
RedbConversationStore — recovery happens after LoadPathAsync,
provider-agnostic.
redb.Route.Exec — child stdout/stderr decoded with the host's OEM codepage on Windows
ExecProducer now sets ProcessStartInfo.StandardOutputEncoding /
StandardErrorEncoding to the host console's active codepage (cp437,
cp932, cp936, cp949, …) on Windows, falling back to UTF-8 on Linux/macOS.
Without this, .NET defaulted to UTF-8 when reading the redirected
streams while cmd.exe / fsutil / wmic / net emit OEM bytes — the
mismatch surfaced as U+FFFD replacement characters in redbExec.Stdout
and the downstream JSON tool body, breaking LLM agents on
Japanese / Chinese / Korean / Greek / Turkish-locale Windows hosts (any
non-Latin OEM codepage).
Adds a dependency on System.Text.Encoding.CodePages 9.0.0 — the BCL
only ships ASCII/UTF-8/UTF-16/UTF-32 encodings on .NET; cp932/cp936/cp949
require CodePagesEncodingProvider.
redb.Route.Llm.Mcp — stdio client transitions to Dead on transport failure
McpClientBase.OnTransportFailed now sets Status = McpClientStatus.Dead
in addition to failing pending requests. Previously, when an stdio child
process exited unexpectedly (or the read pump tripped), the client failed
in-flight requests but kept reporting Healthy, so subsequent
tools/call requests went through the producer and silently hung waiting
on a defunct stdin. The producer's if (Status is Dead) throw short
circuit was unreachable. The fix makes process death immediately
observable both at the registry level and at the producer level.
redb.Route — OnException declared inside a nested scope is now hoisted to route level (Camel parity)
RouteDefinition.CreateProcessor only scanned the top-level route
outputs for inline OnException blocks. An OnException declared inside a
nested scope — Transacted(), Traced(), Metered(), Throttle(),
Filter(), … — was never hoisted. Worse, the orphaned definition was then
compiled by the enclosing scope's pipeline builder via its silent
CreateProcessor fallback, which emitted the handler chain as an inline
pipeline step: the exception handler body executed on every exchange
with Exception == null, corrupting healthy requests (e.g. overwriting the
request body with an error response). OnWhen / Handled / redelivery
settings were silently ignored.
Two changes:
- Recursive hoisting. The route compiler now collects
OnExceptiondefinitions from the entire definition tree (depth-first, declaration order) and wraps the full route body with the handler envelopes — Apache Camel parity:onExceptionis route-scoped regardless of where it appears textually. Wrapping order is unchanged: last declared = outermost. - Fail-fast instead of silent fallback.
OnExceptionDefinition.CreateProcessorno longer compiles the handler chain as a standalone pipeline. A hoisted definition compiles to a no-op at its declaration site; a definition the compiler cannot hoist (e.g. declared inside aCatch/Finallyblock or another exception-handler pipeline) now throwsInvalidOperationExceptionatStart()with placement guidance, instead of corrupting traffic at runtime.
Found in redb.Identity: the /connect/token route wraps its body in
Transacted(...), so its OnException<InvalidOperationException> OAuth
error mapper ran inline on every token request and replaced the form
parameters with an error body before the OpenIddict extract step —
unsupported_grant_type/HTTP 400 on perfectly valid requests.
3.0.1
Added
DSL — flat fluent navigation across nested scopes
redb.Route(DSL) — added a new universalEnd()extension method onIRouteDefinitionand a full set of typedEnd*()extension methods (EndFilter,EndChoice,EndWhen,EndOtherwise,EndSplit,EndMulticast,EndAggregate,EndCircuitBreaker,EndThrottle,EndDebounce,EndLoop,EndTryCatch,EndOnException,EndTransaction,EndLog,EndResequence,EndTraced,EndMetered,EndIdempotentConsumer,EndSaga). Each typedEnd*()walks theParentchain looking for a scope of the requested type and returns its parent route. This means a single.EndChoice()call from deep insideChoice → When → Split → Loglands directly at the route root — semantically identical to chaining.EndLog().EndSplit().EndChoice()but more concise when the intermediate scopes do not need extra steps. Each helper throws a preciseInvalidOperationExceptionwhen called outside a matching scope.redb.Route(DSL) — addedWhen(...)andOtherwise()as extension methods onIRouteDefinition. They walk theParentchain to find the enclosingChoiceDefinitionand dispatch to its instance method, so a sibling branch can be opened immediately after a sub-scope closes — for example.Choice().When(p).Split(...).EndSplit().When(p2).Process(...).EndChoice()now compiles and behaves the same as the equivalent nested-lambda form. Instance methods onChoiceDefinition/WhenDefinition/OtherwiseDefinitionkeep precedence over the extensions, so existing call sites are unaffected.redb.Route(DSL) — added a focused test fixture (DeepNestedDslTests, five scenarios) coveringChoice/When/Otherwise/Split/RichLogcomposition,TryCatchwith rich logging insideDoCatch<T>, mixed typed and universalEnd*()closers, cascadingEndChoice()from deep inside, and the diagnosticInvalidOperationExceptionraised whenEnd*()is called outside any matching scope.
Removed
Legacy RouteStep AST
redb.Route(DSL) — removed the legacyRouteStep/RouteStepProjectionAST and theRouteDefinition.Stepsprojection. TheProcessorDefinitiontree built by the fluent DSL is now the single source of truth for route construction; everything that used to readSteps(Normalizer, Saga, integration tests) now usesCreateProcessordirectly. The legacy files have been moved out of the shipping assembly intotmp/oldRoute/for reference only.
Changed
DSL — single source of truth via CRTP base (RouteDefinitionBase<TSelf>)
redb.Route(DSL) — the leaf DSL (To,Process,ProcessAsync,SetBody,SetHeader,SetProperty,RemoveHeader,RemoveProperty,Transform,Validate,Marshal/Unmarshal,ConvertBody,Stop,Delay,Sample,BeginTransaction/Commit/Rollback,SetPattern,Respond,Bean,StreamCaching,Throw*,Log*, plus every scope-opener:Filter,Choice,Split,Multicast,Loop,Aggregate,IdempotentConsumer,Throttle/Debounce/KeyedThrottle,Metered,Traced,Resequence,Transaction,Saga,OnException,OfType<T>,CircuitBreaker,TryCatch, etc.) is now defined exactly once in a new generic CRTP base,RouteDefinitionBase<TSelf>, instead of being duplicated across 27 scope-definition classes. Each typed leaf method returnsTSelf, so chaining always preserves the current scope's concrete type — e.g..Filter(p).To("a").SetHeader("k","v")keeps you onFilterDefinition,.Choice().When(p).To("a")keeps you onWhenDefinition, and only the explicitEnd*()/End()step exits the scope. There is no behavioural change for end users; the public DSL surface and route AST shape are identical to 3.0.0.redb.Route(DSL) —RouteDefinitionis now a thinRouteDefinitionBase<RouteDefinition>subclass that retains only route-level concerns:RouteId,From,AutoStart,Cluster,ProcessingTimeout,RoutePolicy,OnExceptionhoisting, andCreateProcessor. All other behaviour is inherited.redb.Route(DSL) — every pipeline-scope class (FilterDefinition,ChoiceDefinition/WhenDefinition/OtherwiseDefinition,CircuitBreakerDefinition/FallbackDefinition,LoopDefinition,SplitDefinition/MulticastDefinition,TryCatchDefinition/CatchDefinition/FinallyDefinition,IdempotentConsumerDefinition,OnExceptionDefinition,TransactionDefinition,SagaDefinition,MeteredDefinition,TracedDefinition,ResequenceDefinition,ThrottleDefinition/DebounceDefinition/KeyedThrottleDefinition,AggregateDefinition,OfTypeDefinition<T>,OfTypeFilterDefinition<T>) now inherits fromRouteDefinitionBase<TSelf>and contains only its own scope-specific configuration (options, branch openers,End*()navigation,CreateProcessoroverride). Per-class duplicates of the leaf DSL have been removed.redb.Route(DSL) —IRouteDefinitionremains the canonical cross-version contract;RouteDefinitionBase<TSelf>provides explicit interface implementations for every leaf method (split into a partial file,RouteDefinitionBase.IRouteDefinition.cs), so existing extension methods, test mocks, andAction<IRouteDefinition>configurators continue to bind unchanged.redb.Route(DSL) — non-pipeline definitions (LoadBalancerDefinition,ScatterGatherDefinition,NormalizerDefinition,RichLogScopeDefinition) intentionally remain onProcessorDefinition: they have no childOutputspipeline and no leaf DSL — they are configuration builders, and inheriting the CRTP base would have inflated their public surface with methods (To,Process, …) that are semantically invalid in those scopes.
Fixed
redb.Route(DSL) —IRouteDefinition.GetContext()now correctly returns the owningIRouteContextwhen called on any nested scope (WhenDefinition,LoopDefinition,TracedDefinition,CatchDefinition, etc.). Previously it relied onself as RouteDefinition, which only matched the route root; after the CRTP refactor scope classes inherit fromRouteDefinitionBase<TSelf>(not fromRouteDefinition), and the cast silently returnednullinside any scope. The accessor now walks theParentchain up to the owningRouteDefinitionand returns itsContext. This restoresContext_IsAvailable_In{Choice,Loop,Traced,DoTry}Scopesemantics for extension methods that read context at DSL build time.redb.Route(DSL) —SagaDefinition.SetParentis no longer required: the parent link is now established uniformly throughAddOutput, which matches every other scope and removes a small inconsistency in the AST build path. Existing user code is unaffected.
3.0.0
Added
DSL — full Camel parity, single canonical RouteDefinition
redb.Route(DSL) — Package A "enterprise EIP closure": the parallel v2 type tree (IRouteDefinition2,RouteBuilder2,BlockStack,ExceptionRouteDefinition, the v1OldRouteCompiler, the v1 typedAbstractions/Typed/IRouteDefinition.cs, etc.) has been collapsed into a single canonical surface —IRouteDefinition/RouteDefinition/RouteBuilder. The route AST is now exclusively built fromIProcessorDefinitionnodes, each of which compiles itself viaCreateProcessor(IRouteContext); there is no separate compiler class. The previous "v2 DSL → bridge → legacy compiler" indirection has been removed.redb.Route(DSL) —IRouteContextis now propagated down the definition tree via aParentchain, so any nested*Definitioncan reach the owning context (logger factory, services, idempotent repositories, policy factories) without explicit threading.redb.Route(DSL) —RouteStepProjection: a read-only canonical projection of theIProcessorDefinitiontree intoRouteSteprecords, exposed asRouteDefinition.Steps. Intended for diagnostics, validation, and tooling (e.g. route visualisers); it is not used by the runtime compiler.FromStep,ToStep,FilterStep(with optionalSubStepsbody),ChoiceStep,SagaRouteStep, etc. all flow through this projection.redb.Route(DSL) —RouteBuilder.DefinitionsandRouteBuilder.ExceptionDefinitionsare nowpublic(previouslyinternal). This unblocks downstream test fixtures and tooling that need to introspect the route AST afterBuild().redb.Route(DSL) —OnExceptionDefinitiongained the fluent settersLogStackTrace(bool)andLogExhausted(bool)to match the rest of the CamelonException(...)builder surface.
Dynamic endpoints (Camel toD() / dynamic wireTap / dynamic enrich)
redb.Route(DSL) —DynamicEndpointResolver: per-instance producer cache keyed by the URI resolved at runtime. Three constructors accept a string template (${header.xxx}/${property.yyy}/${body}placeholders), anIExpressioninstance, or a rawFunc<IExchange, string>. Producers are tracked viaRouteContext.TrackProducer(...)for graceful shutdown.redb.Route(DSL) —ToDynamicProcessor+ToDynamicDefinitionimplement Camel'stoD(...)—IRouteDefinition.ToD(string|IExpression|Func).redb.Route(DSL) —WireTapDynamicDefinition,EnrichDynamicDefinition,PollEnrichDynamicDefinitionand matchingIRouteDefinition.WireTap(...)/Enrich(...)/PollEnrich(...)overloads that accept a dynamic URI.EnrichProcessorandPollEnrichProcessorgained an alternate constructor taking aDynamicEndpointResolver; theirProcesschooses between the resolver and the cached producer at run time.redb.Route(DSL) — string-template expression DSL:SetBodyExpression(...),SetHeaderExpression(...),SetPropertyExpression(...)onIRouteDefinition.redb.Route(DSL) —LogDefinition.LogStaticDefinitionauto-upgrades toTemplateLogProcessorwhen the configured message contains a${...}placeholder, so users get template-interpolation without a separate API.redb.Route(Core) —RouteContextnow registers the currentILoggerFactoryinto its service collection so processors built from.Log(...)/ template expressions can resolve their logger without extra plumbing.
Tests
redb.Route(Tests) — new DSL reference suites that pin Camel semantics with extensive scenario coverage:Reference/DslChoiceReferenceTests.cs(~767 lines),Reference/DslDoTryReferenceTests.cs(~441 lines),Reference/DslFilterReferenceTests.cs(extended). These are the authoritative compatibility specs for Choice/When/Otherwise, TryCatchFinally and Filter scope semantics.redb.Route.Tests.Core— twelve tests (RedbRouteExtensionsTests,RedbTransactedActionTests) were rewritten on top of the realRouteDefinition+Exchangepipeline, removing the previousIRouteDefinitionmock-based scaffolding.
IBM MQ diagnostics
redb.Route.IbmMq— diagnostic timing aroundMQGET. The consumer emits aDebug-levelMQGET blocked for {N}mslog entry for any blocking get longer than ~50 ms. This was originally raised atInformationwhile diagnosing a ~500 ms producer→consumer latency in production; it has been lowered toDebugso it stays silent under default verbosity and only lights up when ops explicitly enable IBM MQ diagnostics.IbmMqProducer/IbmMqMessageHelper/IbmMqEndpoint/IbmMqComponentreceived the supporting plumbing.
Known limitations
redb.Route.IbmMq— ~500 ms minimum end-to-end latency on the managed client. The managed IBM MQ .NET client (amqmdnetstd.dll) used by this package is not event-driven onMQGETwithMQGMO_WAIT. It carries an internal polling tick of ~500 ms that is independent of theWaitIntervalsupplied inMQGMO:WaitIntervalonly governs the upper timeout, not the lower delivery-granularity bound. As a result the typical producer→consumer latency on this transport is ~500 ms even after channel reconfiguration (we have validatedSHARECNV(1)onDEV.APP.SVRCONN— it does not change the floor). The native (unmanaged) client is event-driven but requires the IBM MQ Client redistributable to be installed on the host, which is not viable for self-contained .NET deployments and is therefore not used here.Planned fix: rewrite
IbmMqConsumer.ReceiveLoopAsyncto use the managed async-consume API (MQQueue.Cb(...)+MQQueueManager.Ctl(MQOP_START, ...)). With the callback path the broker pushes messages and per-message latency drops to ~0. Tracked for a future release; the change is non-trivial because the loop becomes callback-driven (different cancellation, back-pressure and lifecycle model than the current poll loop). See the in-sourceKNOWN ISSUEblock inIbmMqConsumer.csfor details.Field diagnosis recipe. Enable
Debugonredb.Route.IbmMq.IbmMqConsumerand inspect theMQGET blocked for {N}mslog line:N ≈ 500 msconsistently → managed-client polling tick; the MQCB rewrite above is required.N < 50 mswhile end-to-end latency is still ~500 ms → the bottleneck is on the producer side (PUT missing a flush or an extra round-trip), not the consumer.
Added — Telemetry (carried over)
redb.Route(Telemetry) — shared telemetry identity. BothMeterandActivitySourcenow use a single canonical nameredb.Route, exposed via theRouteActivitySource.TelemetryNameconstant (also surfaced asRouteActivitySource.SourceNameandRouteMetrics.MeterName). OTel collectors can subscribe once and get both signals.redb.Route(Telemetry) —RouteTelemetryExtensions.StartTransportSpan(...)helper that opens a transport span with the conventional OpenTelemetry semantic attributes (messaging.system/db.system/http.method/rpc.system/network.transport, plusredb.route.endpoint,messaging.destination.name,messaging.operation). Returnsnullwhen no listener is registered (zero overhead).redb.Route(Telemetry) —ProcessorMetricsgained 16 new instruments covering the previously-unmeasured EIP processors:- WireTap:
redb.route.wiretap.dispatched,redb.route.wiretap.failed - Multicast:
redb.route.multicast.branches,redb.route.multicast.failed_branches - Recipient List:
redb.route.recipientlist.recipients - Aggregator:
redb.route.aggregator.completed,redb.route.aggregator.timed_out,redb.route.aggregator.inflight_groups - Idempotent Consumer:
redb.route.idempotent.duplicate,redb.route.idempotent.passed - Retry:
redb.route.retry.attempts,redb.route.retry.success,redb.route.retry.exhausted - Saga:
redb.route.saga.completed,redb.route.saga.compensated,redb.route.saga.failed - Dead Letter:
redb.route.deadletter.sent
- WireTap:
redb.Route(Telemetry) —MeteredProcessornow enriches every metric point with the new tagsredb.route.endpoint(canonical endpoint URI) andredb.route.scheme(transport scheme such ashttp,kafka,postgres) in addition to the existingredb.route.id.- Transport spans — 16 producers now open a transport span via the new
helper, producing OpenTelemetry-compliant span trees from the route pipeline
down to the wire:
Http,Sql,Sql(procedure),Grpc,MqttNet,AzureServiceBus,Redis,Elasticsearch,Tcp,S3,GenericFile(covers File / Sftp / Ftp),Firebase.Storage,Firebase.Firestore,Firebase.Fcm,WebSocket,SignalR. The five previously-instrumented transports (Kafka,RabbitMQ,IbmMq,Amqp,Mail,Ldap) keep their existing spans unchanged.
Changed
redb.Route(DSL) —IOldRouteDefinitionrenamed toIRouteDefinitionand all consumer projects (redb.Route.Controllers,redb.Route.Core,redb.Route.Validation.Adapters,redb.Route.Tests.Core) realigned. The Camel-style canonical name is now the single name across the public API.redb.Route—MeteredProcessorconstructor signature gained two optional parametersendpointUriandendpointScheme. Existing call sites that only pass(inner, routeId)continue to work;RouteContextnow wires the endpoint URI and scheme so dashboards can slice metrics per endpoint.redb.Route—InstrumentedProcessor.ActivityExtensions.RecordExceptionusesActivity.AddException(...)on NET9+ and falls back to a manualActivityEvent("exception", ...)withexception.type/exception.message/exception.stacktracetags on NET8, matching the OpenTelemetry exception-recording convention on both target frameworks.
Removed
redb.Route(Legacy) — the entire v1 compiler stack has been removed:OldRouteCompiler(~907 lines),OldRouteDefinition(~1500 lines partial),OldRouteDefinition<TIn>,OldRouteBuilder/OldInlineRouteBuilder,OldCompiledRoute,BlockStack,ExceptionRouteDefinition,IOldRouteDefinition, theLegacy/Abstractions/Typed/IRouteDefinition.cs,Legacy/Extensions/*, the v2→v1 bridges (RouteBuilder2BatchBridge,RouteDefinition2BridgeBuilder,ProcessorDefinitionWrapperStep), and theIRouteDefinition2/RouteBuilder2parallel surface. TheLegacy/folder no longer exists.RouteContext._builders/RouteContext._routesare nowList<RouteBuilder>/List<CompiledRoute>directly, with no intermediate adapter.redb.Route— five stale code comments still referencingOldRouteCompiler/OldRouteDefinition(inRouteStep,NormalizerDefinition,SagaDefinition,AggregatorProcessor,IdempotentConsumerProcessor) were rewritten in terms of the current type names; explanatory intent preserved.
Notes
- Pipeline EIP semantics.
PipelineProcessornow strictly follows the Camel Pipeline contract: between steps, anOutproduced by stepiis merged intoInand cleared before stepi+1runs; on the final stepOutis left as-is and is not synthesised fromIn. InOut callers should therefore consume the reply asexchange.Out ?? exchange.In. This was previously documented inline inPipelineProcessor.cs; recording it here as the authoritative engine contract. Downstream conventions (e.g. the Identity layer's "business processors write toIn.Body, do not pre-createOut") sit on top of this contract without changing it.
Tests
redb.Route.Tests— newTelemetry/InMemoryTelemetryTests.csusing the OpenTelemetry SDK in-memory exporters (OpenTelemetry.Exporter.InMemory) to verify: shared meter/activity-source name, WireTap dispatched/failed, Multicast branches/failed-branches, Idempotent passed/duplicate, Retry attempts/success/exhausted, transport-span semantic tags,MeteredProcessorendpoint/scheme tag enrichment, andActivity.AddExceptionevent emission.- Per-transport telemetry smoke tests — added
*TelemetrySmokeTests.csfiles (and one Firebase pair appended toFirebaseIntegrationTests) covering all P1 transport spans: Http, Tcp, WebSocket, Grpc, Sql, SqlProcedure, GenericFile, MqttNet, Redis, S3, Elasticsearch, SignalR, AzureServiceBus, Firestore, Firebase Storage. Each test builds a real endpoint, runs the producer throughOpenTelemetry.Sdk.CreateTracerProviderBuilder() .AddSource(RouteActivitySource.SourceName).AddInMemoryExporter(...), and asserts the conventional semantic attributes (http.method/network.transport/db.system/messaging.system/rpc.system/redb.system, plusredb.route.endpointandmessaging.destination.name). Docker-dependent tests are tagged[Trait("Category","Integration")].
Pending (integration smoke)
- (none — completed below; see
### Testsfor the per-transport smoke sweep.)
Fixed
redb.Route.Ldap(tests) —LdapEndpointOptionsTests.Validate_ZeroPageSize_*andLdapComponentTests.CreateEndpoint_InvalidPageSize_Throwswere updated to match the (already-shipped) behaviour wherePageSize=0legitimately disables the paged-results control. The tests now assert thatPageSize=0is accepted and that onlyPageSize < 0throws.redb.Route.Firebase(tests) —FirestoreEndpointOptionsTests.Validate_NoCredential_NoEnvVar_Throwsnow captures and restores theGOOGLE_APPLICATION_CREDENTIALSandFIRESTORE_EMULATOR_HOSTenvironment variables in atry/finallyto avoid racing withFirebaseIntegrationTests.InitializeAsync, which setsFIRESTORE_EMULATOR_HOSTfor the whole test host.redb.Route.Firebase(tests) — xUnit collection-level race fixed.try/finallyalone was not enough: by default xUnit runs test classes in different collections concurrently within an assembly, so option-validation classes that mutateFIRESTORE_EMULATOR_HOST/GOOGLE_APPLICATION_CREDENTIALScould still overlap with the live-emulator integration suite that reads them. IntroducedFirebaseEnvSensitiveCollection([CollectionDefinition("FirebaseEnvSensitive", DisableParallelization = true)]) and applied[Collection("FirebaseEnvSensitive")]to all four env-sensitive classes (FirestoreEndpointOptionsTests,FirebaseStorageEndpointOptionsTests,FcmEndpointOptionsTests,FirebaseIntegrationTests). Result: 149/149 PASS, no intermittentEmulator environment variable 'FIRESTORE_EMULATOR_HOST' is not setfailures.redb.Route(dev/test infra) —docker-compose.tests.yml: the Azure Service Bus emulator (servicebus) hadSQL_SERVER: azuriteconfigured, but Azurite is blob/queue/table storage and does not speak TDS. The emulator host therefore crash-looped on startup (initial run created MDFs in the container's writable layer, subsequent restarts failed with Cannot create file '/var/opt/mssql/data/SbGatewayDatabase.mdf' because it already exists), killing the AMQP listener mid-suite and producing AMQP transport failed to open because the inner transport tcpNN is closed on the consumer side. Added a dedicatedsqledgeservice (mcr.microsoft.com/azure-sql-edge:latest) withACCEPT_EULA=Y/MSSQL_SA_PASSWORD, changedservicebus.environment.SQL_SERVERtosqledge, declared the dependency, and bumpedstart_periodto60sto cover SQL Edge warm-up. This is a test-infra change only; published packages are not affected.
Fixed
redb.Route—WireTapProcessorno longer propagates the caller'sCancellationTokeninto the fire-and-forget tap branch. Previously, when the main pipeline was cancelled (e.g. an HTTP request was aborted by the client), an in-flight audit/notification tap could be killed mid-write — typically surfacing as a failedExecuteNonQuery/Commiton the audit store. The tap branch now runs withCancellationToken.Noneand is only torn down on host shutdown, which matches the EIP "InOnly, detached" semantics of WireTap.
2.0.2
Changed
redb.Route.Core— bumpedredb.Coredependency to2.0.2.redb.Core 2.0.2renamesEavSaveStrategy→PropsSaveStrategy; no API changes inredb.Route.Coreitself.
2.0.1
Fixed
redb.Route.Http—HttpConsumer.WriteResponseno longer echoes request headers back into the response. The original request header names are remembered on the exchange (redbHttp.RequestHeaderNamesproperty) and skipped when copying headers fromexchange.In(which acts as the fallback response message whenOutis not set).redb.Route.Http— invalid header values (control characters and non-ASCII bytes that Kestrel would reject) are now filtered out instead of crashing the response pipeline.redb.Route.Http— internal framework headers (redb*,Camel*) are stripped from outgoing responses.redb.Route.Http— body-less InOut responses (HTTP 302 redirects, 204 No Content, Set-Cookie-only replies) continue to propagateLocation/Set-Cookie/ etc. correctly; header copying remains unconditional and only the body write is gated onBody is not null.redb.Route.Ldap— service-account authenticated endpoints (bindDnis set) no longer reuse pooled connections. Active Directory could report "successful bind must be completed" on a pooled socket that was TCP-connected but no longer bound server-side. Such connections are now created per-operation and disposed on release.redb.Route.Ldap—PageSize=0is now a valid value that disables the RFC 2696 paged-results control entirely, for LDAP servers that do not support it. Validation acceptsPageSize >= 0.redb.Route.Ldap—LdapReferralExceptionraised during a search withfollowReferrals=falseis now logged at Debug and the result iteration breaks cleanly instead of bubbling up.
Changed
redb.Route.Http—HttpConsumer.HandleRequestwrapsWriteResponsein a try/catch that logs the failing method, path, route id and whether the response had already started, to aid diagnosing "response already started" errors.redb.Route.Ldap— service-accountBindswitched from the 4-argument overload (with explicit protocol version) to the 2-argumentBindAsync(dn, password, ct). TheprotocolVersionoption is no longer forwarded to the bind call (LDAPv3 default of the underlying client applies).
2.0.0
Changed
- License re-stated as Apache-2.0 as part of the RedBase 2.0 release
alignment. Previous public release (
1.0.4) carried the same license text inLICENSEbut was tagged as MIT in some README badges; all metadata is now consistent (Apache-2.0in csproj, README badges, and CONTRIBUTING). - Every nupkg now ships
LICENSE+NOTICEfiles (Apache 2.0 § 4). - Contributions are accepted under Apache-2.0; see
CONTRIBUTING.md. - Version bumped to
2.0.0to align with the RedBase 2.0 release train (root packages also moved 1.3.0 → 2.0.0). No source-level API changes vs 1.0.4.
1.0.4
First public NuGet release. The library has been production-tested since 1.0.0.
Added
Core engine (redb.Route)
- Fluent DSL:
From → Process → Topipeline definition viaIRouteDefinition RouteBuilderbase class for encapsulating route logic in dedicated classes- Two-phase architecture: define (record
RouteSteplist) → compile (RouteCompilerbuilds processor chain) - 24 EIP pattern processors: Filter, Choice, Split, Aggregate, WireTap, Multicast, RecipientList, DynamicRouter, Loop, Delay, Resequencer, Enrich, PollEnrich, IdempotentConsumer, Throttle, CircuitBreaker, Retry, DeadLetterChannel, DoTry/DoCatch/DoFinally, Transacted, Respond
- Expression engine:
Body,Header,Property,Constant,JPath,XPath,StringExpression(Expr),Exchange - 17 predicate methods:
isEqualTo,isNotEqualTo,isGreaterThan,isLessThan,isGreaterThanOrEqualTo,isLessThanOrEqualTo,isBetween,contains,startsWith,endsWith,regex,In,isNull,isNotNull,Handled,ExceptionHandled - String expression templates:
${header.name},${body},${property.key} - Built-in components:
Direct,SEDA,Timer,Log,Mock - Validation: JSON Schema (
JsonSchemaValidator), XSD (XsdValidator), predicate (PredicateValidator) - Serialization: JSON and XML marshal/unmarshal
- Error handling:
OnException<T>with max redeliveries, exponential backoff, dead-letter routing - OpenTelemetry: distributed tracing (
Traced) and metrics (Metered) per route and per step - Structured logging DSL:
.Log(LogLevel).Message().Header().ShowRouteId() InOutexchange pattern supportRouteIdfor route identification and introspectionRouteEngineOptionsfor telemetry and metrics configuration- Multi-target:
net8.0,net9.0,net10.0
Transports
redb.Route.Kafka— consumer/producer, consumer groups, SASL/SSL, transactions, Confluent.Kafka 7.xredb.Route.RabbitMQ— queues, exchanges, DLX, priority, TTL, quorum queues, RabbitMQ.Client 7.xredb.Route.Redis— Pub/Sub, Streams (consumer groups), KV, Lists, Sorted Sets, Geo, StackExchange.Redisredb.Route.Sql— ADO.NET polling consumer, query/batch producer, stored procedures, provider-agnosticredb.Route.Http— HttpClient producer, Kestrel consumer, CORS, auth, TLS, named URL parametersredb.Route.Grpc— GrpcChannel client, Kestrel server, binary message exchangeredb.Route.File— polling consumer with glob, read locking, idempotency; atomic producer with temp-fileredb.Route.Sftp— SSH.NET, key/password auth, proxy, glob, chmod, recursive traversalredb.Route.MqttNet— MQTT 5.0, QoS 0/1/2, shared subscriptions, retained, TLS, MQTTnetredb.Route.Amqp— AMQP 1.0 (Artemis, Azure SB, Amazon MQ, Qpid), AMQPNetLiteredb.Route.Mail— SMTP producer, IMAP/POP3 consumers with IDLE push, attachments, OAuth, MailKitredb.Route.Tcp— text-line, length-prefixed, raw framing, TLS, InOut request-replyredb.Route.WebSocket— ClientWebSocket producer, Kestrel server consumer, ping/pong, subprotocolredb.Route.Quartz— Cron expressions, interval timers, Quartz.NET thread poolredb.Route.AzureServiceBus— queues, topics, sessions (FIFO), PeekLock/ReceiveAndDelete, batch sendredb.Route.Elasticsearch— 9 producer operations (index, update, delete, bulk, etc.), polling consumer, Elasticsearch 8.xredb.Route.Firebase— Firestore (CRUD, queries, batch), Cloud Storage, FCM; shared credential providerredb.Route.Ftp— FluentFTP, passive/active, FTPS/TLS, jail-path protection, idempotencyredb.Route.IbmMq— IBM MQI, queues, topics, transactions, RPC, message groups, W3C telemetryredb.Route.Ldap— LDAP/AD search, CRUD, authentication, change tracking, Novell.Directory.Ldapredb.Route.S3— AWS S3 + MinIO, multipart upload, SSE (S3/KMS/C), presigned URLs, versioning, Glacier restoreredb.Route.SignalR— Hub consumer (server), client producer (HubConnection), broadcast producer (IHubContext)
Integration & adapters
redb.Route.Core—RedbIdempotentRepositorybacked by redb.Core props storage;IRedbServiceaccess from routesredb.Route.Controllers—RedbController, attribute routing, parameter binding, 4 dispatchers (generic, HTTP, SignalR, gRPC)redb.Route.GenericFile— shared base for File, FTP, SFTP (abstract consumer/producer, options, file-ops interfaces)redb.Route.Validation.Adapters—FluentValidationMessageValidator<T>,DataAnnotationsValidator, DSL extensions