All notable changes to BabelQueue.Core are documented here.
The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.
The envelope wire format is versioned separately by meta.schema_version
(currently 1) — see the contract at babelqueue.com.
- Runtime GDPR field encryption (ADR-0030) — the SDK-enforcement half of the
x-gdpr-sensitivegovernance keyword: an opt-in, producer/consumer pair that encrypts exactly thedatafields a schema declared sensitive, leaving the wire envelope frozen.- New
BabelQueue.Gdprnamespace: the caller-providedICipherinterface (string Encrypt(byte[])/byte[] Decrypt(string)) — bound to a KMS / Vault / HSM / tokenisation service, so the core pulls no crypto dependency (GR-7) — plus a referenceAesGcmCipheron the in-boxSystem.Security.Cryptography.AesGcm(AES-256-GCM, random 12-byte nonce, 16-byte tag, base64; the key is the caller's, no key management).Gdpr.Protect(data, schema, cipher)/Gdpr.Unprotect(...)are standalone, opt-in helpers, andProtectedFieldExceptionis the typed wrong-key error. - Sensitive paths come from the schema, not the message.
SchemaSensitivity.SensitivePathsextracts everyx-gdpr-sensitivemark (booleantrueor a non-empty string category) from a per-URN schema — nested objects (profile.full_name) and array items (addresses[].line) included. Parsing the keyword is validation-neutral (the payload validator ignores it), so annotating a schema is never a breaking change (GR-1). - The envelope stays frozen (GR-1).
Protectrewrites only values insidedata: a marked leaf's value is canonically JSON-encoded (the codec's compact, relaxed-escaping options) and replaced by the cipher's ciphertext string. It adds/renames/removes no envelope field,meta.schema_versionstays 1,trace_idis untouched (GR-4), and a ciphertext value is a JSON string sodatastays pure JSON (GR-3) — an SDK without the key still carries the envelope.Unprotectis the byte-for-byte inverse (numbers restore tolong/double, objects toDictionary, matchingSystem.Text.Json); an absent field is skipped, a non-string leaf is left untouched (idempotent), and a wrong key throwsProtectedFieldExceptionso the message takes the retry / dead-letter path. - Strictly opt-in and backward compatible (GR-6). Validate cleartext — before
Protecton the producer and afterUnprotecton the consumer — because a schema constraining a sensitive field would otherwise reject the ciphertext string. (Note: the type and namespace are bothGdpr, so alias the type at the call site, e.g.using GdprFields = BabelQueue.Gdpr.Gdpr;.)
- New
- Transactional outbox helper (ADR-0029) — an opt-in, producer-side fix for the
dual write: persist the message into the same database, in the same transaction
as the business data (so it commits or rolls back atomically with it), then a separate
relay publishes the durable rows. No distributed transaction; exactly-once handoff
into the broker, at-least-once on the wire as always.
- New
BabelQueue.Outboxnamespace:IOutboxStore(the DB-agnostic persistence contract —SaveAsync/FetchUnpublishedAsync/MarkPublishedAsync/MarkFailedAsync, all async +CancellationToken), theOutboxwriter (WriteAsync(envelope, ct)),OutboxRelay(FlushAsync/DrainAsync) over an injectableOutboxPublisherpublish seam, theOutboxRecord/OutboxRelayResultrecords and a referenceInMemoryOutboxStore. - The caller owns the transaction boundary.
Outbox.WriteAsyncencodes via the frozen codec and callsIOutboxStore.SaveAsyncinside the transaction the caller already opened — it never begins/commits anything. The store binds to the caller's own DB over ADO.NET, so the core takes zero new dependencies (GR-7). - The relay publishes the stored bytes verbatim — it never decodes, rebuilds or
re-encodes the envelope, so the body that reaches the broker is byte-identical to
what was stored (
schema_versionstays 1, GR-1/GR-5;trace_idpreserved, GR-4). A throwing publish marks the row failed and leaves it pending (one poison row never blocks the batch), with a bounded, linearly-growing, capped backoff (injectable async delay so tests stay instant).DrainAsyncloops until a pass makes no progress, with a hard safety ceiling. - Fully opt-in and backward compatible (GR-6); a production deployment binds
IOutboxStoreto a real DB table, the in-memory store is for tests / single-process demos. Per the ADR, relay claim/lock (so two relays don't double-publish a row) is the adapter's concern; the in-memory reference does not implement it.
- New
- Replay-bypass — an out-of-band side-effect guard for DLQ replay (ADR-0027). A
deliberate
Redrive.RedriveAsyncre-runs the handler and re-fires its external side-effects (a second charge, a duplicate email);Idempotency.Wrapstops an accidental duplicate, not the intended reprocess. This closes that gap.- New
Redrive.Options(Bypass: true)stamps abq-replay-bypasstransport header on each redriven message;Redrive.Itemgains aBypassedflag. It takes effect only when the transport implements the new optionalRedrive.IHeaderPublisher(PublishWithHeadersAsync(queue, body, headers)) — otherwiseBypassis a no-op and the message is still redriven (Bypassed: false), exactly like the Go reference. - New
Replay.IsReplay(headers)+Replay.BypassExternalEffectsAsync(headers, effect)consume-side guard (plus theReplay.HeaderReplayBypassconstant): a handler wraps its external, non-idempotent side so a replay skips it while the idempotent core still runs. - The marker rides beside the frozen envelope on the out-of-band header carrier
(
IReadOnlyDictionary<string,string>to read), never inside it (schema_versionstays 1, GR-1;trace_idpreserved, GR-4) — the same seam as thetraceparentheader. Zero new dependencies (GR-7). Fully opt-in and backward compatible: a header-less message behaves exactly as before. Per-adapter transport wiring (BabelQueue.Sqs/BabelQueue.Redis/BabelQueue.MassTransit) is the documented follow-up, like ADR-0028's.
- New
- W3C
traceparenttransport-header propagation (ADR-0028, OTel v0.2) — true cross-hop span parent-child linkage layered over the v0.1trace_idcorrelation (ADR-0025). NewBabelQueue.Tracing.Traceparentexposes the W3C inject/extract —Inject(headers, activity?)writes the activeActivity's span context as atraceparent(andtracestate) onto an out-of-band header carrier;RemoteParentFromHeaders(headers)parses a deliveredtraceparentinto a remoteActivityContext;Format/Parseimplement the frozen W3C format directly. New header-aware overloads:Telemetry.PublishAsync(urn, data, IDictionary<string,string> headers, send, queue)injects the producer span'straceparentinto the carrier (and still stampstrace_id), andTelemetry.Wrap(handler, IReadOnlyDictionary<string,string> headers)starts the consumer span as a child of the producer span when the delivered message carries a validtraceparent— else it falls back to the v0.1trace_id-derived parent (no regression). Opt-in.- The carrier (
IDictionary<string,string>to write /IReadOnlyDictionary<…>to read) is the .NET counterpart of the GoHeaderPublisher/ReceivedMessage.Headersand NodeHeaderCarrierseams: out-of-band metadata that rides beside the frozen envelope, never inside it. - Zero new dependencies (GR-7): built only on the in-box
System.Diagnostics.Activity/ActivityContext/ActivityTraceId— the W3C parse/format is implemented against the frozen format, no propagator library. The wire envelope is untouched (schema_versionstays1, GR-1) andtrace_idis preserved (GR-4). - Per-adapter transport wiring (carrying the header on each transport's native
metadata channel —
BabelQueue.Sqs/BabelQueue.Redis/BabelQueue.MassTransit) is a documented follow-up; this core ships the mechanism.
- The carrier (
1.0.0 - 2026-06-07
1.0.0 — the public API is now SemVer-stable: breaking changes require a MAJOR,
following the deprecation policy. The wire envelope is unchanged
(schema_version: 1). Full reference at babelqueue.com.
- CI enforces Roslyn analyzers (
AnalysisLevel=latest-recommended, warnings as errors) and a coverlet line-coverage gate (/p:Threshold=90). Fixed CA1859 (concrete return type for a private codec helper) surfaced by the analyzers. - GR-8 latency benchmark (
OverheadBenchmarkTests) — asserts the envelope encode/decode path adds ≤2% over plain-JSON serialization vs a conservative 750µs broker round-trip.
0.1.0 - 2026-06-06
EnvelopeCodec— builds (Make,FromMessage), encodes and decodes the canonical{job, trace_id, data, meta, attempts}envelope (schema_version1). The single .NET implementation of the wire format.Envelope/Meta/DeadLetterimmutablerecordtypes.EnvelopeCodec.Encodeemits compact UTF-8 JSON (slashes/unicode unescaped, viaJavaScriptEncoder.UnsafeRelaxedJsonEscaping) — byte-identical to the PHP, Python, Node and Java cores (insertion order preserved).EnvelopeCodec.Urn(...)— resolve the URN (job, acceptingurnas an alias).EnvelopeCodec.Accepts(...)— consumer-side validation (rejects empty URN, unsupportedmeta.schema_version, missingdata, blanktrace_id).DeadLetters.Annotate(...)— additivedead_letterblock builder.- Contracts
IPolyglotMessage/IHasTraceId. UnknownUrnStrategy(Fail/Delete/Release/DeadLetter);BabelQueueException/UnknownUrnException.- Shared cross-SDK conformance suite under
tests/.../conformance/(vendored from the canonicalconformance/set) plus a runner.
- Pre-1.0: the public API may change before the
1.0.0tag. - Zero runtime dependencies (in-box
System.Text.Json); targets .NET 8.