Location: specs/0036-universal-transport-conformance-tests/conformance-status.md
This matrix is the single source of truth for per-configuration conformance state (FR-21 / AC-24).
It is the gate on the FR-10(4)/FR-11 gate-retirement change: the cleanup MUST NOT merge while any
cell remains Unknown.
| Value | Meaning |
|---|---|
Unknown |
Transient; permitted only during the fix phase. The cleanup is blocked while any cell holds this value. |
Pass |
Conforms as generated; both Reactor and Proactor variants are green against a live broker. |
Fixed (#PR/commit) |
Conformed via an in-spec gateway fix linked to the PR or commit. |
Deferred -> #NNNN (sign-off: @iancooper) |
A named, linked, maintainer-signed-off follow-up issue. The handle must name a real maintainer: LedgerSkipCrossCheckAudit rejects a placeholder such as @maintainer or @m, which has the shape of a sign-off while naming nobody. |
- Placeholder rows (transports with no gateway configuration declared yet) occupy a single row
per transport. Their cells may resolve only to
Deferred -> #NNNN (sign-off: @iancooper)— never toPassorFixed— because no generated suite exists to pass. - A behaviour is
PassorFixedonly when both the Reactor and Proactor variants pass against a running broker (FR-14). If only one variant passes, the cell must beDeferred. - Five cells in the FR-2 column carry a pre-identified non-conformance annotation. GCP's four
consumers redeliver immediately (the delay argument is ignored); RocketMQ's requeue is a no-op
holding the message under the native invisibility timeout with no delay applied. These are seeded
ahead of any generation run rather than discovered late. (RocketMQ's has since been fixed: on
2026-10-03 bugfix 0025 made
Requeueset the invisible duration to the delay, and its FR-2 cell isFixed (#4353). GCP's four remain.) - The cleanup gate is evaluated over all twelve targeted transports, not over whichever rows happen to exist.
AWS / SqsFifoandAWS.V4 / SqsFifoFR-9 (delayed send) areFixed (7a0ec644f)for #4390: SQS FIFO does not support per-messageDelaySeconds, so both producers delegate positive FIFO delays to the configuredScheduler. Zero and null delays remain immediate sends; Standard queues continue to use native delays up to 15 minutes and the scheduler for longer delays. Both FIFO providers wire the generatedConformanceHarnessMessageScheduler, which waits before publishing the already-stamped message directly throughSqsMessageProducer, preserving its group and deduplication identifiers. Reactor and Proactor delayed-send tests pass against Floci 1.5.19; this verification does not include real AWS. Before the fix, Floci delivered immediately rather than reproducing AWS's parameter rejection, and both variants failed the before-delay assertion. Requeue-with-delay (FR-2) remains native viaChangeMessageVisibilityand is unchanged.AWS / SnsStandardandAWS.V4 / SnsStandardFR-9 (delayed send) areFixed (#4240): SNS has no native delayed publish —SnsMessageProducer.SendWithDelaydelegates a non-zero delay to theIAmAMessageProducer.Schedulerseam (as Kafka does). Two changes were needed: (1) a localizedsrcfix — the syncSnsMessageProducer.SendWithDelaydropped itsdelayargument (passedTimeSpan.Zeroto the inner overload), so the Reactor path published immediately regardless of the requested delay; it now forwardsdelay, matching the async path andSqsMessageProducer. The V4 gateway (src/Paramore.Brighter.MessagingGateway.AWSSQS.V4/SnsMessageProducer.cs) is a separate file and carried the identical bug — the same one-line fix was applied there. (2) A wired harness scheduler (SnsHarnessMessageScheduler, in-scope per the deferral preconditions) that honours the delay by wall-clock and re-publishes to the SNS topic once it elapses (the V4 test project got its own copy). Requeue-with-delay (FR-2) isPassnatively — it is consumer-sideChangeMessageVisibilityon the subscribed SQS queue, not an SNS publish, so it needs no scheduler.AWS / SnsFifoandAWS.V4 / SnsFifoFR-9 (delayed send) areFixed (#4240). Like SQS FIFO, SNS FIFO delegates delayed sends to theSchedulerseam rather than using native per-messageDelaySeconds. The harness scheduler re-publishes to the FIFO topic after the delay, and the delayed message keeps the FIFOMessageGroupId/MessageDeduplicationIdthatFifoMetadataProducerstamped, so the re-publish is a valid FIFO publish. Reuses the sameSnsMessageProducersyncSendWithDelaysrc fix asAWS / SnsStandard(henceFixed). FR-2 isPassnatively (consumer-sideChangeMessageVisibility, which FIFO supports). The SqsFifo dedup trap (a reusedDefaultMessageBuilderyielding byte-identical messages) applies to SnsFifo too and is handled by the sharedFifoMetadataProducer(constant group id + a unique dedup id per send, with content-based deduplication disabled on both the FIFO topic and queue so the explicit ids govern).Redis / RedisMessagingGatewayFR-16 (Nack → redelivery) isDeferred -> #4240: Redis reads destructively via BLPOP/LPOP, so by the time a handler nacks a message it has already been popped and cannot be returned.RedisMessageConsumer.Nack/NackAsyncare therefore documented no-ops (per ADR0039), and — because they neither redeliver nor clear the in-flight set — the nextReceivethrowsChannelFailureException: Unacked message still in flight. Both variants (Reactor + Proactor) fail identically. This is a genuine platform limitation, not a harness gap: faking redelivery in the test harness would mask a capability the gateway genuinely lacks. Contrast Kafka, where Nack leaves the offset uncommitted and the broker redelivers natively (FR-16Pass). The other ten Redis behaviours conform: delay (FR-2/FR-9) via a wiredRedisHarnessMessageScheduler(Redis delegates a non-zero delay to the scheduler seam, as Kafka does —Pass, harness-only); reject→DLQ/invalid + metadata (FR-4/5/6/8/17) via the Brighter-managed DLQ and invalid-message channels (ADR0039), with the invalid-channel read hook implemented in the provider (was aMessage.Emptystub); and requeue/ redeliver + no-channel ack (FR-7/15/22).MSSQL / MSSQLMessagingGatewayFR-16 (Nack → redelivery) isDeferred -> #4240— same root cause as Redis. The MSSQL queue consumer reads destructively from the queue table (the row is removed on read), soMsSqlMessageConsumer.Nack/NackAsyncare no-ops (public void Nack(Message) {}): the message is already gone and cannot be redelivered. The FR-16 arms therefore observeMT_NONEwhere the redelivered message is expected. Both variants fail identically. Genuine platform limitation, not a harness gap (contrast Kafka's uncommitted-offset native redelivery, FR-16Pass). The other ten MSSQL behaviours conform: delay (FR-2/FR-9) via a wiredMsSqlHarnessMessageScheduler(MSSQL delegates a non-zero delay to the scheduler seam, as Kafka/Redis do —Pass, harness-only); reject→DLQ/invalid + metadata (FR-4/5/6/8/17) via the Brighter-managed DLQ and invalid-message channels (ADR0040), whose provider read hooks were already real (no stub, unlike Redis); and requeue/redeliver + no-channel ack (FR-7/15/22).PostgresSQL / PostgresMessagingGatewayisPasson all eleven behaviours — notably FR-16 (Nack → redelivery), which contrasts with Redis and MSSQL (bothDeferredabove). Postgres reads non-destructively:PostgresMessageConsumer.Receiveleases a message by settingvisible_timeout = CURRENT_TIMESTAMP + VisibleTimeout(it does not delete the row);Acknowledgeperforms theDELETE. SoNack/NackAsync— documented no-ops — genuinely redeliver: the visibility lease expires and the message becomes available again, exactly the mechanism the consumer's own comment describes. This is a real capability, not a faked one, so FR-16 isPass(contrast Redis/MSSQL, whose destructive reads pop the message before the nack, and Kafka's uncommitted-offset native redelivery). Delay (FR-2/FR-9) isPassnatively with no scheduler — the producer and consumer bind the delay straight intovisible_timeoutarithmetic (SendWithDelay/Requeue), like AWS SqsStandard. The reject→DLQ/invalid + metadata behaviours (FR-4/5/6/8/17) needed one harness-only fix: the Postgres consumer keys its read onChannelName(WHERE "queue" = ChannelName.Value), but the provider's DLQ/invalid read hooks created their read consumer with a randomDLQ-{uuid}/Invalid-{uuid}channel name instead of the actualDeadLetterRoutingKey/InvalidMessageRoutingKeythe rejection producer wrote to — so the hooks polled an empty queue and observedMT_NONE. Pointing the read-hookchannelNameat the rejection routing key (matching how the mainCreateSubscriptionalignsChannelNamewith the topic) makes all five behavioursPass; the reject-to-DLQ routing itself was already conformant (Brighter-managed DLQ, ADR0041).- #4388 adds separate
RMQ.Async / NativeClassic,RMQ.Async / NativeQuorum, andRMQ.Sync / NativeClassicconfigurations. They useSupportDelay = truewith no scheduler, while the original configurations retain scheduler coverage. Native FR-2 and FR-9 are fixed by queue-specific delayed requeue and clearing the consumed delay instruction. Like the original RMQ configurations, all three use native DLX routing for FR-8, with empty Brighter rejection keys. The generated native suites pass on the pinned RabbitMQ 4.2.6 / plugin 4.2.0-rc.1 broker with Mnesia (84 async and 42 sync generated cases on .NET 10). Focused tests also cover subscriber isolation, exchange-type preservation, nonpositive delays, and Validate/Assume retry provisioning. See native test setup. The historical plugin-retirement notes below describe the scheduler rollout; these supplementary configurations restore coverage of the existing native API without changing the stock-broker suites or claiming RabbitMQ 4.3+ support. RMQ.Async / ClassicisPasson ten behaviours andFixed (#4387)on FR-5 (a separate invalid channel).⚠️ Reference-environment fix first:docker-compose-rmq.yamlpointed atrabbitmq:management(now RabbitMQ 4.3), which hard-rejects the transient non-exclusive queues the gateway declares (INTERNAL_ERROR - Feature 'transient_nonexcl_queues' is deprecated) — every test failed at queue declaration. Pinned it to the CI/reference image (RabbitMQ 4.2, matching CI and the rootdocker-compose.yaml); a fresh data volume is required (downgrading 4.3→4.2 over a stale volume crashes the broker on boot). That image is now the stockrabbitmq:4.2-managementrather thanbrightercommand/rabbitmq:4.2-management-delay: the delayed-message plugin is being retired upstream and the suite never asked for it, so the pins moved to stock images and the one plugin-dependent test was retired (see "The delay plugin is retired" below). The 4.2 pin itself is unchanged — thetransient_nonexcl_queuesrejection on 4.3 was a product forward-compatibility defect, tracked as #4355 and since fixed forRMQ.Async: itsRmqSubscriptionnow defaultsisDurabletotrue, so a default subscription declares a queue 4.3 will accept. Measured against a real 4.3.5 broker: all 80 generatedRMQ.Asyncconformance tests pass, and the run is unchanged on 4.2 (145 passed, 6 skipped, 0 failed).RMQ.SynckeepsisDurable: falseby decision — it targets the RabbitMQ 3.x line throughRabbitMQ.Client6.x, and 3.x permits transient non-exclusive queues. The consequence of that decision, measured so it is not mistaken for a regression: theRMQ.Syncsuite does not run on a 4.3+ broker at all. Against a real 4.3.5 server it is 46 failed / 35 passed / 3 skipped, every failure ontransient_nonexcl_queuesat queue declaration. That is expected, and it is not confined to the tests —RmqSubscription.isDurablestill defaults tofalsein theRMQ.Syncpackage, so a default subscription from that package is rejected by 4.3+ as well.RMQ.Sync's baseline of 81 passed / 3 skipped / 0 failed is on the pinned 4.2 broker indocker-compose-rmq.yaml, which is whatrabbitmq-sync-ciruns and where it is green; do not compare a 4.3 run against it. ShouldRMQ.Syncever need to support 4.3+, the fix is the one #4355 applied toRMQ.Async— move theisDurabledefault totrueand follow it through the consumer construction sites, creator and reader together, since a queue cannot be redeclared under a different durability. The hand-writtenRMQ.Asyncsuite now runs clean on 4.3 as well. 22 of its tests failed there, every one ontransient_nonexcl_queues, because they opted into a transient queue explicitly rather than inheriting the product default:QueueFactory's ownisDurable: falsedefault inTestHelpers.csfed 16 of them, and the rest passedfalsestraight toRmqMessageConsumer. That default and 23 consumer construction sites now say durable. Measured on a real 4.3.5 broker: 145 passed, 6 skipped, 0 failed — the same numbers the suite has always had on 4.2, where it is unchanged (RMQ.Async145/6/0,RMQ.Sync81/3/0). Three of the 23 are mTLS acceptance tests the CI filter excludes: they carry the identical defect and the identical fix, but were not executed (they need a separate mTLS broker andRMQ_MTLS_ACCEPTANCE_TESTS=true). TwoisDurable: falsesites stay by design — the unit test asserting a subscription can still opt out, and the quorum-queue validation test where a non-durable subscription is the exception being asserted — and four_receiversites keep theirs because their test doubles throw inEnsureChannelAsyncbefore any declare reaches the broker.⚠️ The 4.2 pin indocker-compose-rmq.yamlis unchanged: it matches what CI runs, and moving it is a separate decision. Requeue/redeliver + no-channel ack (FR-7/15/16/22)Passnatively — notably FR-16 (RmqMessageConsumer.NackAsync→BasicNackAsync(requeue: true)→ the broker redelivers, contrast Redis/MSSQLDeferred). Delay (FR-2/FR-9)Passvia a wiredRmqHarnessMessageScheduler— the gateway delegates a non-zero delay toIAmAMessageProducer.SchedulerwhenDelaySupported == false(the same scheduler seam proven for Kafka/Redis/MSSQL); the harness presents a plain (non-delay) exchange so this seam is exercised, yielding conformant semantics (deliveredHeader.Delayed == TimeSpan.Zero, honoured delay). The nativex-delayed-messageplugin path is deliberately not used because it is not yet conformant —RmqMessagePublisher.RequeueMessageAsynchardcodesTimeSpan.Zeroand publishes to the default exchange, dropping a requeue delay (FR-2 would redeliver immediately), and a plugin-delivered send arrives carryingHeader.Delayed == delay, tripping the universal message-equivalence assertion; both are larger src fixes tracked under #4240. Reject→DLQ (FR-4/6/17) and metadata (FR-8)Passvia the native DLX under the FR-8 relaxation. RMQ's rejection path is a nativeBasicRejectthat dead-letters through the single configured DLX (x-dead-letter-routing-key); the message reaches the DLQ (evidence: live 4.2 broker, both variants,Assert.NotEqual(MT_NONE, dlqMessage)passes), butBasicRejectmoves the untouched original message (the AMQP frame carries only delivery-tag + requeue), so the gateway stamps no Brighter rejection metadata and the provider'sRejectionMetadataKeysare empty. Per the maintainer-approved FR-8 relaxation (seedecision-log.md), a transport that dead-letters via a native broker mechanism is conformant on routing alone: the canonical templates assert DLQ arrival unconditionally and guard the rejection-metadata sub-assertions onRejectionMetadataKeys.StampsRejectionMetadata(true iff the provider declares non-empty keys). ⭐ This paragraph has an executable counterpart.RejectionMetadataContractAuditnames the four configurations allowed to claim FR-8 on routing alone —RMQ.Async / Classic,RMQ.Async / Quorum,RMQ.SyncandAzureServiceBus— and fails the build if a fifth acquires empty keys while claiming FR-8, or if one of the four starts stamping metadata and this note goes stale. Measured: 24 providers, 8 with all-empty keys. So FR-4/6/8/17Passfor RMQ (routing) while metadata-stamping transports (SQS/Redis/Postgres/MSSQL/GCP ×4, ADRs0038/0039/0040/0041/0078) still assert the full metadata — GCP's four consumers joined this group 2026-09-28 (spec 0037 task 5.11):GcpRejectionRouterstamps the realRejectionMetadataKeyNameskeys via ADR 0078'sRejectionMetadataKeysimplementation in each provider (:588-595), so GCP was never a routing-only candidate for the audit's declared-relaxation set above — see the dated evidence note under the GCP FR-23 paragraph below for the full cell move. FR-5 isFixed (#4387): the subscription exposes an invalid routing key, and unacceptable messages are forwarded with publisher confirmations before the original is acknowledged. Other rejection reasons and the fallback when no invalid destination is configured retain the native DLX behavior. The generated FR-5 test always asserts invalid-channel arrival and DLQ absence; dedicated gateway tests additionally assert all five metadata fields on the Brighter-managed invalid route. The empty provider keys still describe the native DLQ route.RMQ.Async / QuorummirrorsRMQ.Async / Classicexactly: 10Pass+ FR-5Fixed (#4387). Quorum queues use the same RabbitMQ AMQP gateway (src/Paramore.Brighter.MessagingGateway.RMQ.Async), so every conformance argument that applies to Classic applies to Quorum. Delay (FR-2/FR-9)Passvia the same wiredRmqHarnessMessageScheduler— the Quorum provider presents a plain (non-delay) durable exchange soDelaySupported == falseand the scheduler seam is exercised (deliveredHeader.Delayed == TimeSpan.Zero, honoured delay; same as Classic). Reject→DLQ (FR-4/6/17) and metadata (FR-8)Passvia the native DLX under the FR-8 relaxation — Quorum queues supportx-dead-letter-exchange/x-dead-letter-routing-keyidentically to Classic queues;RejectionMetadataKeysis empty → routing only asserted (same as Classic). FR-16Pass—RmqMessageConsumer.NackAsync→BasicNackAsync(requeue: true)→ broker redelivers, same mechanism as Classic. FR-7/15/22Passnatively. FR-5 isFixed (#4387)via the same confirmed invalid-message forwarding as Classic.RocketMQ / RocketMQMessagingGateway— 9Fixed (#4240)+ FR-2 / FR-15Deferred -> #4240, both variants, on a live RocketMQ 5.5.0 broker (Reactor + Proactor each 21 pass / 2 skip / 0 fail). Every passing cell isFixed(notPass) because of a requiredsrcfix:RocketMqMessageProducerguards the CloudEventsBaggageproperty — an empty baggage'sToString()is""and RocketMQAddPropertythrows on empty, which crashed every send (the sharedDefaultMessageBuilderyields an empty baggage); it is now added only when non-empty.- ⭐ FR-9 (delayed send)
Fixed, NOT deferred — RocketMQ honours delay natively via the broker timer wheel: for aDelay-type topic the producer setsSetDeliveryTimestamp(now + delay)(RocketMQ 5.x timer/scheduled messages). No scheduler seam is needed (contrast Kafka/Redis/SNS). The prior belief that FR-9 was a gateway gap was an infrastructure artifact — the reference compose'screate-topicservice hard-codes arocketmq-5.4.0mqadmin path against a5.5.0image, so theDELAY-typed topics were silently never created; with them created (andtimerWheelEnable=true, the broker default), delayed send delivers after the delay.FR-9 delayed-requeue sibling(with_delay_should_receive_message_again) also uses this native path (its topic isDelay-typed). - FR-4/5/6/8/17 (reject → DLQ / invalid + metadata)
Fixed— RocketMQ has real DLQ and a distinct invalid channel (both Brighter-managed via reject-time re-publish to the routing key), and realRejectionMetadataKeys(originalTopic/originalMessageType/rejectionReason/…), so the FR-8 metadata sub-assertions run for real (contrast RMQ's empty-keys relaxation). The reject path rewritesHeader.Topicto the DLQ/invalid topic (so the message routes there) while preserving the source in theoriginalTopicbag entry; the requeue-exhaustion test's full-message assertion compares against that preserved entry. - FR-7/16/22 (no-channels ack / nack redelivery / plain requeue)
Fixed— when these cells were certified, plain requeue and nack were no-ops that relied on the invisibility lease: the message redelivered when the lease expired, which the canonical retry loops (30 s ceilings) observe. Since bugfix 0025 (2026-10-03)NackandRequeueset the invisible duration on the broker, so redelivery comes in about 2–3 s; the cells are unchanged. - FR-2 (requeue with delay): was
Deferred -> #4240, nowFixed (#4353)(2026-10-03, bugfix 0025).RocketMessageConsumer.Requeuewas a no-op: theChangeInvisibleDurationcall was commented out pending an upstream RocketMQ C# client release. So a requeued message redelivered at the fixed ~10 s invisibility regardless of the requested delay. The canonical FR-2 test could pass by accident (10 s falls inside its 2 s–30 s window), so the cell was a maintainer-signedDeferred(do-not-chase-a-green rule). See the bugfix 0025 note in Rules for the fix; FR-2 was also unmapped in the provider until then. - FR-15 (explicit zero-delay requeue, redeliver within 5 s): was
Deferred -> #4240, nowFixed (#4353)(2026-10-03, bugfix 0025). Same cause: the no-op requeue could only redeliver via the 10 s invisibility lease, so redelivery within 5 s was impossible. See the bugfix 0025 note in Rules. - Harness (test-project) adaptations, no non-Baggage
srcchange:rq_delay/exhausttopics use a longer consumer poll so the genuine ~10 s invisibility redelivery is observed by their single-poll receive arms (the redelivery is real; only the observation window is widened — delay/FR-9 topics keep the short 2 s poll so their before-Darm still yieldsMT_NONE); the delayed-requeue sibling's topic isDelay-typed to use native delivery-timestamp instead of the unwired Scheduler seam; and the assertion compares the source topic via the preservedoriginalTopicbag entry for dead-lettered messages. Reference-env fix:docker-compose-rocketmq.yamlbroker/proxy heap raised (-Xmx128m/-Xmx64m→2g/1g) so the broker sustains the suite instead of degrading under load. - ⭐ Run record (2026-10-02, spec 0037 task 7.30, AC-38, R-22, R-23): the nine
Fixedcells still pass on the AC-24 branch. The store was reset (docker-compose -f docker-compose-rocketmq.yaml down -v; up -d, all 100 compose topics created), then the whole RocketMQ test project ran at52523c32f's code (net10.0): 67 passed / 0 failed / 6 skipped. Every generated test for the nine cells passed in both variants: FR-4 (delivery_error_should_send_to_dlq), FR-5 (unacceptable_reason_should_send_to_invalid_channel), FR-6 (unacceptable_and_no_invalid_channel_should_fallback_to_dlq), FR-7 (no_channels_configured), FR-8 (should_include_metadata), FR-9 (sending_a_delayed_message), FR-16 (nacking_a_message_it_should_be_redeliveredandnacking_first_of_two_messages), FR-17 (unknown_reason_should_send_to_dlq) and FR-22 (requeuing_a_failed_message_should_be_redelivered). FR-16 and FR-22 still pass with 7.10's broker-countResolvein the consumer: the redelivery comes from the invisibility lease. The 6 skips are FR-2 and FR-15 (Deferred -> #4240), both variants, plus the two Skip-marked facts in 7.1's measurement fixture. FR-23 (Fixed (#4353), 7.13) passed in the same run. A watch item: the FR-9 Proactor test (When_sending_a_delayed_message_should_deliver_after_delay_async) passed here but has failed twice on a clean store this month (in 7.11's run on 2026-10-01, and earlier on6b19023fa): the message was received inside the test's 2 s "before delay" window. Not investigated.
- ⭐ FR-9 (delayed send)
MQTT / MqttMessagingGateway— 10Fixed (#4240)+ FR-16Deferred -> #4240, both variants, on a live Mosquitto broker. Evidence run with every cell un-skipped (the state that earned the FR-16 deferral): Reactor 14 pass / 2 fail (FR-16) + Proactor 16 pass / 2 fail (FR-16) for the generated canonical suite. Final certified state, FR-16 now carrying its Deferred Skip: the scoped~MessagingGatewaysuite is 55 pass / 4 skip / 0 fail (the 4 skips = FR-16's two arms × both variants); all pre-existing non-generated tests pass. Every passing cell isFixed(notPass) because of requiredsrcfixes: (1)MQTTMessageConsumer.Receivewas an immediate no-wait return when the internal queue was empty — it now polls in 10 ms increments until a message arrives or the timeout elapses, matching the contract other transports implement; (2)MQTTMessageProducer.SendWithDelaydid not callBrighterTracer.WriteProducerEvent, so the producer span was never propagated tomessage.Header.TraceParent— the call was added, matching the Redis / Kafka producer pattern. Harness additions (test-project only):MqttHarnessMessageSchedulerfor delayed-requeue (FR-2) and delayed-send (FR-9), same wall-clock timer + republish pattern as Redis/RMQ/MSSQL; subscriptionBufferSize = 5so the Channel wrapper can buffer the 4 messages from the multi-message test without overflowing its internal queue (Channel's hard cap is 10); scheduler wired intoCreateProducer(FR-9) and into the consumer factory (FR-2).- ⛔ FR-16 (Nack → redelivery)
Deferred -> #4240— MQTT is pub/sub with no acknowledgment concept.MqttMessageConsumer.Nackis a documented no-op. Messages are received from an in-memoryConcurrentQueue<Message>populated by theApplicationMessageReceivedAsyncevent handler; once dequeued onReceive, the message is gone. MQTT QoS 1 delivers to connected subscribers exactly once with no broker-side requeue path. Both arms of FR-16 (When_nacking_a_message_it_should_be_redeliveredandWhen_nacking_first_of_two_messages_should_redeliver_nacked_then_receive_second) observeMT_NONEafter nack on a 30 s ceiling — same root cause as Redis (destructive BLPOP) and MSSQL (row deleted on read). Three deferral preconditions met: evidence recorded (live Mosquitto broker, both variants), fix is not localized (requires a redelivery buffer or QoS-level redesign insrc), maintainer sign-off.
- ⛔ FR-16 (Nack → redelivery)
RMQ.Sync / RmqSyncMessagingGateway— 10Fixed (#4240)+ FR-5Fixed (#4387), both variants, on a live RabbitMQ 4.2 broker with management + delay plugin image (Reactor + Proactor each 19 pass / 1 skip / 0 fail in the canonical generated suite). Every passing cell isFixed(notPass) because of a requiredsrcfix:RmqMessageProducer.DisposeAsync()created aTaskCompletionSourcethat was never completed and returnednew ValueTask(tcs.Task)— soawait producer.DisposeAsync()hung indefinitely; it now returnsValueTask.CompletedTask. Delay (FR-2/FR-9)Fixedvia a wiredRmqSyncHarnessMessageScheduler— the harness presents a plain (non-delay) exchange soDelaySupported == falseand the scheduler seam is exercised (wall-clock timer re-publishes the same message object, preserving the original ID via theOriginalMessageIdHeaderNamebag entry, asserted byRmqMessageAssertion). Reject → DLQ (FR-4/6/17) and metadata (FR-8)Fixedvia native DLX under the FR-8 relaxation —BasicRejectdead-letters to the configured DLX;RejectionMetadataKeysis empty → routing only asserted (same mechanism as RMQ.Async). FR-15 (explicit zero-delay requeue)Fixed—RequeueMessagerepublishes with a new AMQP message ID (original stored inOriginalMessageIdHeaderName); asserted correctly byRmqMessageAssertion. FR-16/22Fixednatively. FR-5 isFixed (#4387)via confirmed invalid-message forwarding. A mandatory return or failed confirmation leaves the original unacknowledged. The native DLX path remains in use for other rejection reasons and fallback.- Test-isolation fix (harness, required): the generated suite originally declared its own xUnit
collection (
RmqSyncMessagingGateway) while the hand-written broker tests useRMQ. xUnit runs distinct collections in PARALLEL, so the two suites hit the same broker concurrently and two tests failed only in the combined run (the generated FR-8 Proactor arm, and the pre-existing hand-writtenRmqMessageProducerRequeuingMessageTests.When_posting_a_message_via_the_messaging_gateway) while both passed in isolation.CollectionNameis nowRMQ, serialising generated + hand-written against the one broker.⚠️ RMQ.Async has the same latent split (Classic/QuorumvsRMQ) but was certified on a configuration-scoped filter (~MessagingGateway.Quorum.) that never ran the hand-written tests alongside, so it never surfaced. ⚠️ Scoped-suite caveat — 9 pre-existing mTLS failures are NOT from this work. The full~MessagingGatewayfilter is 78 pass / 3 skip / 9 fail; all 9 failures areRmqMutualTls{Acceptance,Observability,QuorumObservability}Tests, which need a TLS-configured broker (certs, port 5671) thatdocker-compose-rmq.yamldoes not provide — they fail in <1 ms. Verified pre-existing: a baseline run at commitb1a5027d0(before any RMQ.Sync onboarding) fails with the identical 9 mTLS tests. After the collection fix there are zero non-mTLS failures. The conformance conclusion rests on the generated canonical suite, which is fully green apart from the FR-5 Deferred skips.
- Test-isolation fix (harness, required): the generated suite originally declared its own xUnit
collection (
AzureServiceBus / AzureServiceBusMessagingGateway— 9 of 11 cellsPass, verified in CI against a real namespace (2026-09-07). FR-5 and FR-9 remainDeferred -> #4240 (sign-off: @iancooper).- How this was established. Probe PR #4317 (closed unmerged, as #4308 was) flipped all 11 cells so
the generator emitted no
Skip, applied the #4309 fix, and merged the #4310 fix so the run measured the right thing.azure-cijob101853410391against baseline run34127954869: the failing set went from 30 distinct tests to 6. The 26-strongMT_NONEwave collapsed to 2, and the 2UriFormatExceptionfailures went to 0. ⚠️ The earlier "ALL ELEVEN Deferred on INFRA grounds" note was superseded, and its stated reason was wrong twice over. It said no ASB behaviour "has been observed at all" because credentials are unset. That is true locally but not in CI:.github/workflows/ci.ymlsuppliesBrighterTestsASBConnectionStringfromsecrets.BRIGHTERTESTS_ASB_CONNECTION_STRING. The follow-up correction then read the resulting 30 failures as "cannot complete a plain round trip", which was the right call on the evidence then available but the wrong cause: the round trip was broken by #4309, a test-harness ordering defect, not by anything about ASB's behaviour.- Why the round trip was failing — #4309.
AzureServiceBusChannelFactoryprovisions nothing, and the ASB subscription is created lazily by the consumer's first receive. Every generated test sends before it first receives, and an ASB topic with no subscription attached silently discards the message.AzureServiceBusMessageGatewayProvidernow provisions the topic and subscription before handing out a channel — which is what the hand-written ASB tests have always done.⚠️ Whether the gateway should do this eagerly (it receivesMakeChannelsand ignores it, unlike AWS and GCP) is deferred to ADR 0066 / PR #4259, the Infrastructure Provisioner. The fix here is harness-side by decision, not by oversight. - FR-5 (reject → invalid channel) stays
Deferred, and is expected to. ASB dead-letters natively: the DLQ is the built-in sub-queue and there is no separate invalid-message channel — the provider's own doc comment says so, andCreateSubscriptionrecords thatinvalidMessageRoutingKeyhas "no structural effect on the ASB subscription itself". This is a genuine platform difference, not an unfixed defect. Tracked under #4240. - ⭐ Superseded 2026-10-04: FR-9 is now
Pass. #4318 was diagnosed and closed by #4445 (merged 2026-09-28): the gateway was not ignoring the delay. The before-delay receive was the channel's first, and its one-time cold-start cost (a new AMQP client, plus the subscription check) ran past the 5 s delay before the "2 s" receive began.AzureServiceBusMessageGatewayProvidernow warms each channel with one bounded receive before returning it, and no production code changed, so the cell isPass, notFixed. #4445 left the two tests skipped pending a live run. Flipping the cell un-skipped exactly the 2 FR-9 tests (Reactor/Proactor), andazure-ciagainst the real namespace is the evidence: ASB has no local emulator. The bullet below is the original diagnosis, kept as the record. - FR-9 (delayed send) stays
Deferred— a REAL, newly-visible defect. See #4318. It was masked twice: first by #4309, then by theUriFormatExceptionof #4310. With both cleared, the failure isAssert.Equal() Failure: Expected MT_NONE, Actual MT_EVENTon the before-delay arm — i.e. a message sent with a 5 s delay is available within 2 s. The delay is not honoured.⚠️ No existing test could ever have caught this:FakeServiceBusSenderWrapper.ScheduleMessageAsyncdiscardsscheduleEnqueueTimeand callsSend(message), so every hand-written delayed-send test passes identically whether or not the gateway schedules anything. - Broker attempt and why it failed. ASB is a cloud service with no container story in this repo:
there is no
docker-compose-*asb*.yaml, the credentialsASBCreds.csrequires (BrighterTestsASBConnectionString/BrighterTestsASBNameSpace) are both unset, theazCLI is absent, and no Service Bus emulator container is present.ASBCreds.ASBClientProviderthrows at runtime when neither env var is set, so every canonical test would fail on client construction rather than on a behaviour. - The wiring is real and ready to flip. Task 54 (
b70db98e9) landed a genuineAzureServiceBusMessageGatewayProviderimplementing both provider interfaces, and the 22 canonical tests generate. The DLQ read hook is a real bounded read — aServiceBusReceiverwithSubQueue.DeadLetterpolling the built-in$DeadLetterQueueentity — not aMessage.Emptystub. RejectionMetadataKeysare allstring.Empty— a native-DLQ declaration, NOT an unfilled stub.AzureServiceBusConsumer.Rejectdead-letters natively viaServiceBusReceiver.DeadLetterAsync(lockToken, reason, description)and stamps no Brighter bag keys, exactly as RMQ does. Under the maintainer-approved FR-8 relaxation a native-dead-letter transport is conformant on routing alone.⚠️ This is the same empty-keys situation the GCP guardrail flagged, but the opposite reading applies here because the native dead-letter path is real and verified in source — contrast GCP, whereReject==Acknowledge(discards).⚠️ Thedotnet testleg of this row's RALPH-VERIFY cannot pass on a machine without ASB credentials, and did not pass BEFORE this work either. Measured: a baseline run at commit32d1a6f9f(before ASB onboarding) is 112 pass / 10 fail / 0 skip — the project's hand-written broker tests (ASBConsumerTests,ASBProducerTests,LargeAsbMessageProducerTests) already threwASB ConnectionString or Namespace not set. After onboarding it is 118 pass / 18 fail / 24 skip: the 22 canonical tests are correctly skipped carrying theirDeferred: #4240markers, and the 8 added failures are the generator's non-canonical companions (basic post/receive, multi-message, multi-thread post, activity-context) which are not FR-mapped and so take no ledger-driven Skip — they fail on the identical missing-credentials exception, not on any behaviour.⚠️ That last clause holds only where the credentials are absent. In CI, where they are supplied, the same companions fail onMT_NONEand on409 (Conflict)— see the correction above. Precedent: this is the same resolution asGCP / Stream+/ StreamOrdering, where the scoped suite could not be run locally and the no-Unknownledger is the gate, withdotnet testverification deferred to real infrastructure.- To close these deferrals: supply
BrighterTestsASBConnectionString(orBrighterTestsASBNameSpace) against a real namespace, or stand up the Service Bus emulator, flip the cells, and regenerate. No code change is expected to be needed to run — only to fix whatever the run then reveals.
- How this was established. Probe PR #4317 (closed unmerged, as #4308 was) flipped all 11 cells so
the generator emitted no
Kafka / Classic— row renamed fromKafka / Standard. Upstreammasterrenamed the Kafka gateway configuration keyStandard->Classic(KIP-848 work, PR #4233) and renamedKafkaMessageGatewayProvider->KafkaClassicMessageGatewayProvider. The row is the same configuration and the same certified result (11/11Pass); only the name follows the config.Kafka / Consumer— ALL ELEVEN cellsPass, both variants, on the reference Kafka broker (apache/kafka:4.0.2, the imagedocker-compose-kafka.yamlandci.ymlboth use). Scoped suite~MessagingGateway.Consumer.= 34 pass / 0 skip / 0 fail. This configuration arrived from upstreammaster(KIP-848 consumer group protocol, PR #4233) after the conformance templates had already landed, so it needed onboarding rather than fixing. Harness-only — nosrcchange, hencePassnotFixed:KafkaConsumerMessageGatewayProviderwas a near-clone of the pre-conformance Classic provider, so the resolved Classic provider was ported across verbatim (DLQ + invalid routing keys, the real invalid-channel read hook,RejectionMetadataKeys, the wiredKafkaHarnessMessageScheduler), keeping only the one behavioural difference:GroupProtocol = new ConsumerGroupProtocol()on the subscription. The row therefore mirrorsKafka / Classicexactly, which is the expected outcome — the two configurations shareKafkaMessageConsumer/KafkaMessageProducerand differ only in group coordination.- The new group protocol is genuinely exercised, not silently downgraded. The broker advertises
ConsumerGroupHeartbeat(68)/ConsumerGroupDescribe(69)as usable, and pollingkafka-consumer-groups.sh --list --typeduring a Consumer-configuration run observes groups of typeConsumer(KIP-848), notClassic. This was checked explicitly because a librdkafka fallback to the classic protocol would have certified Classic behaviour twice under a second row. CollectionNameisKafkafor all three Kafka configurations, so the generated suites and the hand-written broker tests serialise against the one broker (the xUnit cross-collection parallelism trap recorded underRMQ.Sync).- Full
Paramore.Brighter.Kafka.Testsproject: 188 pass / 0 fail with bothkafkaandschema-registryup.⚠️ Runningkafkaalone fails the two hand-writtenKafkaMessageProducerHeaderBytesSendTestsarms onConnection refused (localhost:8081)— they need the schema registry; that is infra, not conformance.
- The new group protocol is genuinely exercised, not silently downgraded. The broker advertises
GCP / StreamandGCP / StreamOrderingFR-16 (Nack redelivers) areFixed (#4449)(2026-09-30, bugfix 0024). The Stream consumer'sNack/NackAsyncwere no-ops. On a stream that left theSubscriberClientcallback parked: the message was never redelivered, and at the default flow control of one the whole subscription stalled.Nacknow repliesNackto the client (GcpStreamMessage.Reject(), the same path asRequeue), which releases the message for immediate redelivery.GCP / Pulland/ PullOrderingFR-16 stayDeferred: PullNackis still a no-op that waits out the ack deadline, recorded as a follow-up.⚠️ GCP / StreamOrdering's two-message fact is intermittent on the Pub/Sub emulator. Two messages share one ordering key, and the first is nacked. In about 5 of 12 probe runs, the second message was acked and the nacked one was never redelivered within 60 s; the acked second message came back at about the client's 60 s lease instead. Brighter's call path is the same in every run, so this looks like the emulator's ordering-key redelivery, not the fix. That is UNVERIFIED until it is run against real GCP, which is pending the non-emulator run from #4321.GCP / Stream(no ordering) passed 20/20. CI is unaffected:ci.ymlexcludesCategory=GcpPubSubStreamandGcpPubSubStreamOrdering, so it runs no Stream tests. Evidence is inbugfixes/0024-gcp-stream-nack-no-redelivery/bugfix.md.RocketMQ / RocketMQMessagingGatewayFR-2 (requeue with delay) and FR-15 (zero-delay requeue) areFixed (#4353)(2026-10-03, bugfix 0025).- The defect.
RocketMessageConsumer.RequeueandNackwere broker no-ops. TheChangeInvisibleDurationcall was commented out, pending a client fix that RocketMQ.Client 5.2.1 already has (spec 0037 task 7.1). So a requeued or nacked message came back only when the receive-time invisibility lease lapsed (10 s here), and any requested delay was ignored. - The fix.
Requeue/RequeueAsyncset the invisible duration to the delay.Nack/NackAsyncset it to zero.- A negative delay counts as zero. A delay above the broker's 12 h maximum is held at 12 h. The broker rejects both with response code 40011.
- If the broker call fails, the consumer logs a warning,
Requeuereturnstrue, and the message comes back when its lease lapses. A throw would stop the pump, andfalsewould make the pump ack, and so lose, the message.
- Measured on broker 5.5.0 with a 10 s lease:
Requeue(0),Requeue(3 s)andNackall redelivered at about 12.9 s before the fix. A directChangeInvisibleDurationof 1 s, 5 s or 25 s redelivered at 5.7 s, 7.1 s and 27.4 s.DeliveryAttemptstill advances, so the FR-23 cell is unaffected. - FR-2 was not in the provider's topic map, so it ran against a non-existent topic. It is now mapped
to
gen_{r,p}_rq_after(NORMAL), and passed on arrival: the lease alone fell inside its 2 s–30 s window. FR-2 is not a regression test for this fix. - FR-15 is the regression test. Before the fix it failed at 12.1 s and 13.1 s, against a 5 s limit.
- Hand-written tests pin what the templates cannot, Reactor and Proactor:
- a delay longer than the lease is honoured;
Nackredelivers within 6 s;- a 13 h delay is held, not dropped to the lease;
- a negative delay redelivers as zero;
- a failed broker call neither throws nor returns
false.
- The FR-15 template reads the first message on its topic, not its own by id. Like the generated reject
tests, it is only valid on a clean RocketMQ store. Evidence is in
bugfixes/0025-rocketmq-requeue-nack-broker-noop/bugfix.md. - Run (2026-10-03, net10.0): a clean store (
down -v, 112/112 topics), fullParamore.Brighter.RocketMQ.Tests.- Result: 83 / 0 / 2. The 2 skips are the 7.1 measurement facts.
- Compared by name with the 8.7 run, the only change is FR-2 and FR-15 (Reactor and Proactor) going from skipped to passed.
- FR-23 2 / 2.
- The defect.
Added after review of PR #4297 noticed a coverage regression: retiring the legacy
When_requeuing_a_message_too_many_times_should_move_to_dead_letter_queue template removed the only
coverage of a message reaching the dead-letter queue by exhausting its delivery budget, and none
of the other canonical behaviours replaced it.
That is distinct from FR-4. FR-4 is an explicit Reject; FR-23 never rejects anything — the budget
runs out on its own, which is the behaviour ADR 0040
and ADR 0046 actually specify. A gateway that
redelivered for ever would pass every other DLQ behaviour in this suite.
The column opened with all 24 cells Deferred, and cells move to Pass only on evidence — a
Pass recorded without a broker run is exactly what this ledger exists to prevent.
Redis / RedisMessagingGatewayisPass, both variants green against a live Redis (docker-compose-redis.yaml), satisfying the both-variants rule (FR-14).- All three
Kafkaconfigurations arePass, both variants each, against a live broker (docker-compose-kafka.yaml). MSSQL / MSSQLMessagingGatewayandPostgresSQL / PostgresMessagingGatewayarePass, both variants each, againstdocker-compose-mssql.yamlanddocker-compose-postgres.yaml.RMQ.Async / ClassicandRMQ.Async / QuorumarePass, both variants each, againstdocker-compose-rmq.yaml. These two are the first configurations to reach the DLQ by the broker's own dead-lettering rather than a Brighter-side republish, and they are what exposed the two transport-specific assumptions described below.RMQ.Sync / RmqSyncMessagingGatewayisPass, both variants green against the same brokerdocker-compose-rmq.yamlstands up. It shares the DLX mechanism with the twoRMQ.Asyncconfigurations, so it lands on the loosened identity and delivery-count bounds described below. It needed no broker the async pair did not already need: the conformance suite never asks for the delayed-message plugin, so a stockrabbitmq:*-managementimage serves all three RMQ cells.- The other 15 remain
Deferred, and two different things hold them. Ten are blocked on a product defect rather than on a broker run: the eightAWSandAWS.V4cells on #4341,MQTTon #4351, andRocketMQon #4353. Re-running those changes nothing until the defect is fixed. Of the remaining six,GCP×4 andAzureServiceBuscannot be run against local infrastructure at all — GCP's emulator implements neither of the two APIs the DLQ path needs (see below), and ASB has no emulator — sogcp-ciandazure-ci, which run against real cloud projects, are the only evidence that can move those five. So no FR-23 cell is still reachable by a local broker run: all 15 are either blocked on a product defect (10) or reachable only through CI against real cloud infrastructure (5). Superseded for the eightAWS/AWS.V4cells by the 2026-09-27 evidence note, "The eight AWS cells areFixed (#4341)", below: #4341 is fixed and those cells now pass. Superseded forMQTTby the 2026-10-04 evidence note at the head of the MQTT section below: #4351 is fixed (#4441) and the cell isFixed (#4441).
Regenerated tree audit: dotnet test tests/Paramore.Brighter.Test.Generator.Tests -f net10.0 — 307 pass / 0 fail. git status --short after ./generate-test.sh reported no diff (tree was committed with each task as required).
FR-23 — all nine already-conformant configurations re-verified against the regenerated templates:
| Configuration | compose file | Generated suite (Reactor+Proactor) | FR-23 Reactor | FR-23 Proactor |
|---|---|---|---|---|
| Redis / RedisMessagingGateway | docker-compose-redis.yaml |
71 pass / 0 fail / 4 skip | Pass | Pass |
| Kafka / Classic | docker-compose-kafka.yaml |
(see Kafka row below) | Pass | Pass |
| Kafka / Consumer | docker-compose-kafka.yaml |
(see Kafka row below) | Pass | Pass |
| Kafka / PartitionKey | docker-compose-kafka.yaml |
(see Kafka row below) | Pass | Pass |
| Kafka (all three, combined) | docker-compose-kafka.yaml |
118 pass / 0 fail / 0 skip | — | — |
| MSSQL / MSSQLMessagingGateway | docker-compose-mssql.yaml |
141 pass / 0 fail / 4 skip | Pass | Pass |
| PostgresSQL / PostgresMessagingGateway | docker-compose-postgres.yaml |
55 pass / 0 fail / 0 skip | Pass | Pass |
| RMQ.Async / Classic | docker-compose-rmq.yaml |
(see RMQ.Async row below) | Pass | Pass |
| RMQ.Async / Quorum | docker-compose-rmq.yaml |
(see RMQ.Async row below) | Pass | Pass |
| RMQ.Async (Classic+Quorum, combined) | docker-compose-rmq.yaml |
80 pass / 0 fail / 4 skip | — | — |
| RMQ.Sync / RmqSyncMessagingGateway | docker-compose-rmq.yaml |
62 pass / 0 fail / 3 skip | Pass | Pass |
FR-23 passes on all nine; no Pass/Fixed cell regressed. The RMQ republish path's x-original-message-id fallback (from 3.2) is exercised in the RMQ.Async and RMQ.Sync runs and stays green.
Kafka notes: schema-registry could not start (port 8081 held by the pre-existing rmqproxy container). The two hand-written KafkaMessageProducerHeaderBytesSendTests arms fail on Connection refused (localhost:8081) — documented infra, not conformance. One further hand-written KafkaMessageConsumerUpdateOffsetAsync arm also failed; this is not in the generated suite and not a ledger regression. All 118 generated-suite tests pass.
AWS (Floci) and RocketMQ — FR-2/15/16/22 re-run to verify the 3.1 relaxation keeps Pass cells green:
| Project | Transport | FR-2 (with_delay) | FR-15 (zero_delay) | FR-16 (nacking) | FR-22 (requeuing_redelivered) | Notes |
|---|---|---|---|---|---|---|
| AWS.Tests | all four configs × Reactor+Proactor | 44 pass (total) | — | — | — | Filter matched FR-2/15/16/22 together: 44 pass / 0 fail / 0 skip |
| AWS.V4.Tests | all four configs × Reactor+Proactor | 44 pass (total) | — | — | — | Same result |
| RocketMQ.Tests | RocketMQMessagingGateway | Skip (Deferred) | Skip (Deferred) | 6 fail | 6 fail | See noise note below |
AWS (total filter: with_delay OR zero_delay OR nacking OR requeuing_a_failed_message_should_be_redelivered): 44 pass / 0 fail / 0 skip for AWS.Tests; 44 pass / 0 fail / 0 skip for AWS.V4.Tests. All Pass/Fixed cells remain green. Tested against generator-transport-tests-floci-1 (port 4566) with AWS_SERVICE_URL=http://localhost:4566.
RocketMQ — FR-16 and FR-22 pass after a clean broker reset. The first run failed all six FR-16/FR-22 tests at the first receive, and failed them identically on 82f28ff01, the commit before 3.1. The broker was at fault: even the basic posting test failed, and the rejection tests reported No topic route info in name server. The fix, in order:
docker-compose -f docker-compose-rocketmq.yaml down -v && up -d.docker start rmqproxy. The proxy exits on first start because the broker is not ready yet.- Create the missing topics by hand with
mqadmin updateTopic.create-topicin the compose file makes only a subset of the topicsRocketMqMessageGatewayProvidermaps (for example,gen_{r,p}_nack,gen_{r,p}_nack2andgen_{r,p}_rq_redeliverare absent), and RocketMQ 5 does not auto-create topics through the gRPC proxy. All 40 mappedgen_*topics were created, plus their_DLQ/_Invalidsiblings (120 in all), typed asGetTopicTypedoes:partition_keyFIFO;delayed_msg/requeue_delay/rq_delayDELAY; the rest NORMAL.
After that, FR-16 passes on all four tests (nacking_a_message and nacking_first_of_two, Reactor and Proactor) and FR-22 on both (requeuing_a_failed_message_should_be_redelivered, Reactor and Proactor), about 12 s each on the 10 s invisibility lease. The 3.1 relaxation keeps both Fixed cells green. The compose file's incomplete topic list is a pre-existing local-infra gap. It is out of scope here and not changed.
FR-2 and FR-15 are Deferred for RocketMQ and emitted as Skip — as expected.
MQTT was Deferred; the cell is now Fixed (#4441) — the Proactor no longer deadlocks on the first requeue
⭐ Evidence (2026-10-04): the MQTT / MqttMessagingGateway FR-23 cell moved from Deferred -> #4351 to
Fixed (#4441). #4441 (bb10b8fae, merged
2026-09-27, closing #4351) fixed the deadlock described below at its source. MqttMessagePublisher's
constructor now connects through a nested BrighterAsyncContext.Run(ConnectAsync) rather than
ConnectAsync().GetAwaiter().GetResult(), so the connect's continuation no longer waits on the blocked pump
thread. That one constructor covers the requeue, DLQ and invalid-message producers. The cell was left
pointing at the closed #4351 until this note. Regenerating Paramore.Brighter.MQTT.Tests un-skipped exactly the
2 FR-23 tests (Reactor/Proactor). Against docker-compose-mqtt.yaml, both passed in 5 s each on net9.0 and
net10.0, including the Proactor variant that previously hung. The full MQTT suite: 79 passed / 0 failed / 4
skipped on each framework. The conformance audits re-ran green against the new ledger
(Paramore.Brighter.Test.Generator.Tests, 312 passed). The paragraphs below record how the cell got here.
Their "stays Deferred" statements are superseded by this note.
Measured 2026-09-12 against docker-compose-mqtt.yaml, and tracked as
#4351. The two variants disagree, and
FR-14's both-variants rule is what holds the cell:
| variant | result |
|---|---|
Reactor |
passes in 5 s — the message reaches the DLQ |
Proactor |
hangs indefinitely — killed at 18 minutes, and again by --blame-hang at 180 s |
Instrumenting the Proactor run locates it exactly. The handler is invoked once, and nothing happens after that: no redelivery, no second invocation, no dead-lettering, and the pump never sees the quit message the test enqueues.
The cause is a sync-over-async deadlock in the transport, not in the harness.
MqttMessagePublisher's constructor blocks on its own connect
(MQTTMessagePublisher.cs:54, ConnectAsync().GetAwaiter().GetResult()), and
MqttMessageConsumer creates its requeue producer lazily inside RequeueAsync
(EnsureRequeueProducer()). A Proactor runs its event loop inside BrighterAsyncContext.Run(...)
(Proactor.cs:99), which is single-threaded. So the first deferral constructs the publisher on the
pump's only thread, that thread blocks, and MQTTnet's continuation is posted back to the context it
is blocking. The Reactor survives the identical path because Requeue runs on an ordinary
thread-pool thread, where the continuation has somewhere to go.
Isolated, so the mechanism is not inferred from the symptom: constructing the publisher on a
thread-pool thread completes; constructing the same publisher inside BrighterAsyncContext.Run
times out at 15 s. One passes, one fails, same broker, same configuration.
⭐ This is a product defect on a production path. Any Proactor consumer on MQTT deadlocks the
first time a handler defers. It is also the first defect FR-23 has found that no other behaviour in
this suite could: FR-22 requeues from the test's thread, so it never constructs the producer under
the pump's context and passes — which is exactly the composition FR-23 exists to test.
#4082 named MQTTMessagePublisher.cs:54
as candidate 4 and carried a checklist item for it, but was closed as COMPLETED on 2026-04-27
with the file untouched since b42887af4, which predates it. The item was missed.
Either half of the fix unblocks the cell: stop connecting in the constructor (an async factory, or lazy connect on first publish), or create the requeue producer eagerly with the consumer so it is never built on the pump thread.
GCP ×4 was Deferred; the four cells are now Fixed (#4386) — the budget reads the broker's delivery counter
⭐ Evidence (2026-10-01, spec 0037 task 6.16, AC-19, AC-3 on GCP, AC-30 row 3, NFR-7): the four GCP / *
FR-23 cells moved to Fixed (#4386). The two blockers recorded below no longer stand. The first was the
emulator refusing the IAM calls a native DeadLetterPolicy needs; 5.3 now tolerates those failures, so a
DLQ-backed subscription is creatable on the emulator. The second was the header-carried HandledCount
never advancing; 6.10/6.11 now resolve it from the broker's own delivery counter (DeliveryAttempt on Pull,
GetDeliveryAttempt() on Stream), which 6.7 measured as 1, 2, 3. Regenerating Paramore.Brighter.Gcp.Tests
un-skipped exactly the 8 FR-23 tests (4 configurations × Reactor/Proactor). On a clean emulator
(docker-compose -f docker-compose-gcp.yaml down -v; up -d) all 8 passed, in 4–9 s each. Each one
asserts that the handler was invoked at most RequeueCount (3) times, and that the Brighter DLQ copy
arrived within 60 s carrying rejectionReason == "DeliveryError". The full scoped GCP suite
(Category!=Spanner&Category!=GcpPubSubStream&Category!=GcpPubSubStreamOrdering&Fragile!=CI) showed
151 passed / 50 failed / 30 skipped. The 50 failures match the pre-existing Firestore + GCS baseline
failures (real-GCP-only) by name. The Stream suite
((Category=GcpPubSubStream|Category=GcpPubSubStreamOrdering)&Fragile!=CI) showed 118 passed / 0 failed /
25 skipped. The conformance audits (42) re-ran green against the new ledger. The dated notes below record
how the cells got here. Their "stays Deferred" statements are superseded by this note.
⭐ Repeatability (2026-10-01, spec 0037 task 6.31, AC-22, R-21): the GCP rejection-routing behaviours and
FR-23 gave identical results on two clean-emulator runs, with no gcp-ci. The behaviours run were FR-4,
FR-5, FR-6, FR-8, FR-17 and FR-23, across all four configurations and both variants: 48 generated tests,
selected by template name. The emulator was reset (docker-compose -f docker-compose-gcp.yaml down -v; up -d)
before each run. Run 1 was 48 passed, 0 failed, 0 skipped (34.7 s). Run 2 was 48 passed, 0 failed, 0 skipped
(34.4 s). A per-test diff of the two runs' outcomes was empty.
⭐ Evidence (2026-09-28, spec 0037 task 5.11): the twenty rejection-routing cells (FR-4, FR-5, FR-6,
FR-8, FR-17 × four configurations) moved to Fixed (#4386), and this paragraph's blocker does not
apply to them. This section's two-API blocker (below) is specific to FR-23: FR-23 dead-letters
by a message exhausting its delivery budget under the subscription's own native DeadLetterPolicy,
which EnsureSubscriptionExistsAsync provisions via UpdateIAmRoleForDeadLetterAsync, and that call
is what needs the two APIs the emulator refuses. FR-4/5/6/8/17 are a different mechanism entirely
(ADR 0078): an explicit Reject routes a stamped copy to a Brighter-managed dead-letter or
invalid-message topic addressed by deadLetterRoutingKey/invalidMessageRoutingKey, published by an
ordinary lazy producer — no native DeadLetterPolicy, no UpdateIAmRoleForDeadLetterAsync call, and
so no dependency on Resource Manager or the emulator's absent IAM surface. Ledger regeneration
un-skipped 40 generated tests (20 cells × Reactor/Proactor); all 40 passed on a clean emulator
(docker-compose -f docker-compose-gcp.yaml down -v; up -d), and the full scoped GCP suite
(Category!=Spanner&Category!=GcpPubSubStream&Category!=GcpPubSubStreamOrdering&Fragile!=CI) showed
120 passed / 50 failed / 32 skipped — the 50 failures are exactly the pre-existing 45 Firestore + 5 GCS
baseline failures (real-GCP-only), zero in MessagingGateway, so no cell was reverted.
RejectionMetadataContractAudit/LedgerSkipCrossCheckAudit (44 tests) re-ran green against the new
ledger. FR-23 itself stays Deferred here — this paragraph's blocker is unaffected and still
applies; it is Phase 6/7 territory (ADR 0077, delivery count), not this task's scope.
⭐ Measurement (2026-09-29, spec 0037 task 6.7, AC-39): the emulator's broker delivery counter is
populated and advances on a DLQ-backed subscription. This was run on a clean emulator. The
subscription carried a native DeadLetterPolicy (MaxDeliveryAttempts = 5), which Brighter's channel
factory can now create on the emulator because 5.3 tolerates the IAM failures described below. One
message was deferred on its first two deliveries and acknowledged on the third, and the raw counter
was read on each delivery:
- Pull:
ReceivedMessage.DeliveryAttempt= 1, 2, 3, with the deferral asModifyAckDeadline(…, 0), the same callGcpPullMessageConsumer.Requeuemakes. - Stream:
GetDeliveryAttempt()= 1, 2, 3, with the deferral asSubscriberClient.Reply.Nack.
Both sequences repeated on a second run. Fixture:
tests/Paramore.Brighter.Gcp.Tests/MessagingGateway/Pull/GcpDeliveryAttemptMeasurementTests.cs
(committed Skip-marked). ADR 0077's Measurement outcome under "R-13 (GCP): the branch rule" records
that A-2 held and claims AC-19. The broker counter advances where the header-carried HandledCount
cannot (see the second blocker below), and that is the mechanism 6.10/6.11 wire in. The four FR-23
cells stay Deferred until 6.16 moves them.
⭐ Lease-lapse evidence (2026-09-30, spec 0037 tasks 6.13/6.14, AC-42): GCP / StreamOrdering uses the
keyless alternative. 6.13 measured that a message published with an ordering key is not redelivered while
its first delivery is held (none in 90 s), because ordered delivery withholds a same-key redelivery behind an
outstanding predecessor. So on GCP / StreamOrdering, 6.14 publishes without an ordering key on the same
ordering-enabled subscription, as ADR 0077's stream lease-lapse procedure allows. GCP / Stream uses the
primary procedure. Both need the client's own AckDeadline set to 10 s as well as the subscription's (lapse at
~15 s rather than ~60 s). See ADR 0077's 6.13 amendment. Tests:
tests/Paramore.Brighter.Gcp.Tests/MessagingGateway/Stream/When_a_gcp_stream_lease_lapses_should_present_greater_delivery_count{,_async}.cs.
Measured 2026-09-12 against docker-compose-gcp.yaml (the cloud-sdk:emulators Pub/Sub emulator on
localhost:8085, with PUBSUB_EMULATOR_HOST and GOOGLE_CLOUD_PROJECT exported). All eight tests —
four configurations × both variants — fail in ~4 s during arrange, before any pump runs. The
behaviour was never reached, so this run is evidence about the infrastructure, not about FR-23.
The DLQ path needs two APIs the emulator does not implement. Creating a subscription that
carries a DeadLetterPolicy routes through GcpPubSubMessageGateway.EnsureSubscriptionExistsAsync
(:235), which calls UpdateIAmRoleForDeadLetterAsync (:481). That method does two things, and
the emulator refuses both:
| call | what happens on the emulator |
|---|---|
ProjectsClient.GetProjectAsync — Cloud Resource Manager, used to derive the default Pub/Sub service account when DeadLetterPolicy.PublisherMember is unset |
Unauthenticated — the request leaves for real GCP. ProjectsClientConfiguration is the one builder hook the four GCP providers do not wire for emulator detection, and Resource Manager is a different service from Pub/Sub, so PUBSUB_EMULATOR_HOST could not redirect it anyway |
PublisherServiceApiClient.IAMPolicyClient.GetIamPolicyAsync on the dead-letter topic |
Unimplemented — the Pub/Sub emulator has no IAM surface at all |
The second was isolated rather than inferred: setting PublisherMember explicitly on the provider's
DeadLetterPolicy skips the Resource Manager call, and the run then fails one line later on
GetIamPolicy with Unimplemented. That probe was reverted; it is recorded here, not committed.
So no amount of harness work makes this cell runnable locally. This is the same resolution as
AzureServiceBus: the scoped suite cannot be run against local infrastructure, and verification is
deferred to real infrastructure — here gcp-ci, which runs against a real GCP project.
GcpPullMessageConsumer.Requeue{,Async} requeues with ModifyAckDeadline(…, 0) (:335, :369),
which returns the message to the subscription without rewriting the stored copy, and
HandledCount is written only on send (Parser.AddHeaders, :307). Every redelivery therefore
arrives reading 0, the pump bumps it to 1, HandledCountReached(RequeueCount) is never true, and the
budget cannot run down. What would dead-letter the message is the subscription's own
MaxDeliveryAttempts, and the DLQ copy would carry HandledCount = 0 — failing this behaviour's
>= RequeueCount - 1 bound. This is the same defect as
#4341 on SQS, whose ChangeMessageVisibility
requeue has exactly this shape. It is recorded here as a source reading, not a measurement: the
emulator blocker above stops the run that would confirm it.
⭐ A product observation worth separating from the cell, filed as
#4354. UpdateIAmRoleForDeadLetterAsync makes
channel creation hard-fail when the project's IAM cannot be read or written. That means an
application creating a DLQ-backed channel must hold resourcemanager.projects.get and
pubsub.topics.{get,set}IamPolicy, which an application service account frequently will not — the
DLQ is usually provisioned by infrastructure-as-code. Tolerating Unimplemented and
PermissionDenied there (log and continue, as the binding may already exist) would both fix that and
make the emulator path usable.
RocketMQ was Deferred; the cell is now Fixed (#4353) — the budget reads the broker's delivery counter
⭐ Evidence (2026-10-02, spec 0037 task 7.13, AC-24, AC-3 on RocketMQ, AC-30 row 4): the
RocketMQ / RocketMQMessagingGateway FR-23 cell moved to Fixed (#4353). Requeue is still a broker no-op,
as recorded below; R-14 allows that on the AC-24 branch. The budget runs down anyway: 7.10 resolves
HandledCount from the broker's own MessageView.DeliveryAttempt, which 7.1 measured as 1, 2, 3 across
lease-lapse redeliveries. Each 10 s lease lapse therefore presents a higher count, and the pump rejects once
RequeueCount is reached. 7.3 makes the header-owned HandledCount win over a stale bag copy on the routed
dead-letter copy, and DeliveryCount.Resolve keeps that stamped count because the copy carries
rejectionReason (7.12). Regenerating Paramore.Brighter.RocketMQ.Tests un-skipped exactly the 2 FR-23 tests
(Reactor/Proactor). On a clean store (docker-compose -f docker-compose-rocketmq.yaml down -v; up -d, all
compose topics created) both passed, in 39 s and 41 s. Each one asserts that the handler was invoked at most
RequeueCount (3) times, and that the Brighter DLQ copy arrived within 60 s with HandledCount >= RequeueCount - 1,
carrying rejectionReason == "DeliveryError". The full RocketMQ suite on a clean store (net10.0): 67 passed / 0 failed / 6 skipped. The
conformance audits (42) re-ran green against the new ledger. The paragraphs below record how the cell got
here. Their "stays Deferred" and "never dead-lettered" statements are superseded by this note.
Measured 2026-09-12 against docker-compose-rocketmq.yaml, and tracked as
#4353. Both variants fail the same way: the
dead-letter poll returns MT_NONE for the whole window. The message is never dead-lettered.
It is not a timing problem, and that was measured rather than argued. RocketMQ redelivers only
when its 10 s invisibility lease expires, so a budget of 3 could plausibly need more than the 30 s
ceiling. Re-running the Reactor variant with the ceiling widened to 150 s — roughly fifteen
redeliveries against a budget of three — still ends in MT_NONE. The widened ceiling was a probe on
the generated file, restored with ./generate-test.sh; it is recorded here, not committed.
The cause is in the transport. RocketMessageConsumer.Requeue (:179) does nothing at all: it
resolves the MessageView from the bag and returns true, with the one call that would act on the
broker commented out —
// Waiting for next RocketMQ C# version, due an issue on ChangeInvisibleDuration
// consumer.ChangeInvisibleDuration(view, TimeSpan.Zero);so the message simply stays invisible until its lease lapses and the broker re-serves the stored
copy. HandledCount is read from the message's published properties (ReadHandledCount, :422) and
written only on send (RocketMqMessageProducer, :157), so every redelivery arrives reading the
value that was originally published. The pump bumps it to 1, HandledCountReached(RequeueCount) is
never true, no Reject is ever issued, and nothing reaches the DLQ — for ever, not merely for 150 s.
⭐ This is the third instance of one defect family, and the most complete. AWS
(#4341) requeues with
ChangeMessageVisibility and GCP with ModifyAckDeadline(0); neither rewrites the stored message,
so neither can spend a Brighter-side budget either. But both of those have a broker-side redrive
(maxReceiveCount, MaxDeliveryAttempts) that dead-letters the message anyway, which is why their
FR-23 tests reach the DLQ and fail on the count. RocketMQ has no such policy wired, so its message
is never dead-lettered by anyone. The common requirement this column keeps finding: a transport
whose requeue does not persist the delivery count cannot exhaust the pump's budget.
2026-10-01: AC-23 measured (spec 0037, task 7.1). R-14's condition holds, so AC-24 is claimed. Two runs
with the Skip-marked fixture tests/Paramore.Brighter.RocketMQ.Tests/MessagingGateway/Reactor/RocketMqDeliveryAttemptMeasurementTests.cs,
broker apache/rocketmq:5.5.0, RocketMQ.Client 5.2.1. Run 1 used a freshly created topic. Run 2 used a store reset
with down -v; up -d. Setup: requeueCount: 3, Reactor, 10 s invisibility lease, and no Requeue, Nack or
ChangeInvisibleDuration call. The raw MessageView.DeliveryAttempt was read from Header.Bag["ReceiptHandle"].
The consumer was disposed and a new one created in the same consumer group before delivery 3.
| Run | Delivery 1 | Delivery 2 | Delivery 3 (new client) | HandledCount as received |
|---|---|---|---|---|
| 1 (fresh topic) | 1 (t+0.1 s) | 2 (t+13.3 s) | 3 (t+25.4 s) | 0, 0, 0 |
| 2 (clean store) | 1 (t+0.2 s) | 2 (t+12.3 s) | 3 (t+24.3 s) | 0, 0, 0 |
The sequence is strictly increasing, and it survives a new client, so the increment is broker-supplied, not the
client-local IncrementAndGetDeliveryAttempt. AC-25 is not applicable on the strength of this measurement. The
counter stays classified approximate (ADR 0077): the Apache RocketMQ retry documentation describes the retry
mechanism but makes no statement that the attempt count is exact. The HandledCount column shows the defect
above as measured: the received header stays at its published value.
ChangeInvisibleDuration works on client 5.2.1. This is a probe in the same fixture, prompted by PR #4263,
which calls it from Nack. SimpleConsumer.ChangeInvisibleDuration(view, TimeSpan.Zero) returned without error,
and the message was redelivered after 2.9 s / 2.3 s (runs 1 / 2) rather than after the 10 s lease (the
lease-lapse control above took 12–13 s). DeliveryAttempt still advanced, 1 → 2. So the "upstream blocker" in the
Requeue comment ("Waiting for next RocketMQ C# version") no longer exists. R-14 lets Requeue stay a broker
no-op on AC-24, so enabling it is a follow-up, not part of this spec.
#4353 was updated with this measurement: #4353 (comment).
The FR-23 attempt cost four failed runs before a single one measured the behaviour, and
rocketmq-ci has been commented out since #3696, so local is the only place this ever runs. Two of
the causes are now fixed in docker-compose-rocketmq.yaml; three remain facts about the broker.
Fixed in the compose file (and verified end to end — the stack was torn down with down -v,
rebuilt, and the FR-23 failure reproduced on infrastructure the compose file alone created):
create-topicused to silently create nothing. It invoked/home/rocketmq/rocketmq-5.4.0/bin/mqadmin, butapache/rocketmq:latestnow ships 5.5.0, so every line failed withnot found— and nothing checks the service's exit. The path is now resolved at run time (ls -d /home/rocketmq/rocketmq-*/bin/mqadmin), so it survives the next version bump.- The conformance topics were absent from its list.
gen_r_exhaust,gen_p_exhaustand their_DLQ/_Invalidcompanions are now created with the rest.
Still true of the broker, and not fixable in compose:
- RocketMQ 5.x does not auto-create topics through the gRPC proxy — a producer on an unknown
topic fails with
No topic route info in name server. Any new generated topic must be added tocreate-topic; nothing will conjure it at first publish. rmqproxycaches topic routes at startup, so a topic created after it started stays invisible untildocker restart rmqproxy. Becausecreate-topicand the proxy come up together, the proxy generally needs one restart after a firstup -d. The proxy also fails outright if it beats the broker's nameserver registration (create system broadcast topic DefaultHeartBeatSyncerTopic failed); starting it again is enough.rmqproxyneeds port 8081, which the Kafka suite'sschema-registrycontainer also binds, so the two stacks cannot be up at once. Stopschema-registryfirst, and restart it afterwards.
⛔ The dead-letter topics are shared and persistent, and residue breaks the identity assertion. The
first two runs failed against a ~6-week-old message. The DLQ read acks what it returns, but it
uses a fresh GUID consumer group each time, so the ack moves only that group's offset and drains
nothing; mqadmin deleteTopic and recreate does not help either, because the consumequeue files
survive. Only docker-compose … down -v followed by up -d gives a clean store — RocketMQ's
store lives inside the container filesystem, not in a named volume.
The finding, as first recorded here: MqttMessageGatewayProvider.GetMessageFromDeadLetterQueue{,Async}
looped 10 times over a 5 s Receive plus a 1 s Thread.Sleep, so a single call took ~60 s. The
FR-23 retry loop wrapped it in a 30 s stopwatch, which therefore could not bound anything: one call
already overran it. The async overload was Thread.Sleep-based inside an async method. Neither
affected the verdict above — the Proactor deadlock is upstream of the DLQ poll — but the ceiling
documented in NFR-2 was not the ceiling MQTT observed.
It was never an MQTT quirk. The same 10 × (5 s + 1 s) loop had been copied into 50 helper bodies
across 13 provider files — every GetMessageFromDeadLetterQueue[Async] and
GetMessageFromInvalidChannel[Async] in the tree bar the already-compliant ones (Kafka's six, RMQ's
invalid-channel pair, GCP's and ASB's invalid-channel helpers, RocketMQ's sync one). So no rejection-
destination arrival anywhere observed NFR-2's documented bound, and the cost was not spread evenly:
AC-5's "DLQ must be empty" check asserts a message never arrives, so it ran the full 60 s on every
run — 8 minutes per CI job on aws-ci alone, across its 8 live invalid-channel tests.
All 50 now attempt a single bounded receive and return, the retry having moved to the caller's loop
where NFR-2 says it lives; the rejection-destination ceiling is stated as 60 s so today's tolerance
is preserved exactly rather than tightened. DeadLetterPollContractAudit fails the build if a helper
starts retrying internally again.
Every cell above was also checked for vacuity the same way: force the pump's budget to int.MaxValue
so it can never be exhausted, and confirm the test goes red. Both variants were probed separately —
CreateChannel and CreateChannelAsync are different paths, and a probe that touches one proves
nothing about the other.
Reactor and Proactor own this rule: they call UpdateHandledCount, test
HandledCountReached(RequeueCount), and reject with RejectionReason.DeliveryError when the budget
is spent. Nothing else does. A test that calls Requeue on a channel never reaches that code, so it
cannot answer the question FR-23 is named for however green it goes.
So FR-23 starts a real pump over the channel and routes it to a handler that always defers, using
the ConformanceDeferredPump scaffolding generated into each configuration's Generated folder.
RequeueTrackingChannel{Sync,Async}
re-implemented the budget rule at channel level in eight providers, converting the Nth Requeue into
a Reject. Every Pass it produced certified the harness's copy of the rule, not Brighter's, and
would have kept certifying it after the two diverged. The substituting branch is gone; the wrapper
now only stamps x-original-message-id, which is bookkeeping rather than behaviour.
The distinction is not academic. Measured on this branch, the channel-level version reported Kafka as
non-conformant — the message kept being delivered after the budget was spent — and the pump-driven
version passes on all three Kafka configurations. The apparent defect was the test bypassing the
code under test. Kafka's DLQ is Brighter-managed (ADR 0046
gives it a DLQ producer, which fires on the Reject the pump issues), and it works.
Kafka's one real gap was in the harness: its three providers were the only ones of the 21 that never
declared requeueCount, so they took Subscription's default of -1. The pump reads that as
DiscardRequeuedMessagesEnabled() == false and never rejects, which left the behaviour untestable
rather than failing. They now declare a budget like every other provider.
Running RMQ made two of FR-23's assertions visible as assumptions about how a message reaches the DLQ rather than whether it does. Both were written against Brighter-side republish, which was the only mechanism the column had seen. Neither is a defect in RMQ.
Identity. The original assertion was dlqMessage.Header.MessageId == message.Header.MessageId.
Most transports carry the id through a requeue untouched. RabbitMQ does not: RmqMessagePublisher
mints a fresh id on republish (Uuid.NewAsString()) and records the first one in the
x-original-message-id header. That is deliberate, and the product already depends on it —
Reactor.cs:500 and Proactor.cs:506 read exactly that header when they log a dropped message. The
assertion now accepts either the message id or x-original-message-id, and nothing else; an
unrelated message matches neither.
Delivery count. The original assertion was HandledCount >= RequeueCount. The pump increments
the count and then tests it, so the delivery that exhausts the budget is the one never republished.
Where the harness puts the message on the DLQ itself, it sends that final in-memory header and the
count reads RequeueCount. Where the broker dead-letters natively — RmqMessageConsumer.RejectAsync
calls BasicRejectAsync and the broker's DLX moves the copy it already holds — the stored copy was
written by the last republish, so it reads one less. Both spent the same budget; they differ only in
which copy gets recorded. The assertion is now >= RequeueCount - 1, the strongest bound true of
both mechanisms, and it still fails anything dead-lettered before the budget ran down.
Both changes live in the shared templates, so every configuration is held to the same rule. The four
configurations that were already Pass were re-run against the revised assertions and stay green.
FR-23 drives a real pump. The other canonical behaviours deliberately do not, and that is a decision rather than an oversight, so it is recorded here.
FR-2, FR-15 and FR-22 call Requeue on the channel; FR-4, FR-5, FR-6, FR-7, FR-8 and FR-17 call
Reject. None starts a pump. Read as "does the pump do the right thing", those tests would be
bypassing the code under test — the objection that made FR-23 change. Read as what they are, a
gateway suite, they are correct: they ask whether this transport honours a requeue or a reject,
which is the only question a transport can answer.
The pump's half of the behaviour is proven separately, against in-memory channels, in
tests/Paramore.Brighter.Core.Tests/MessageDispatch — 86 tests, 41 Reactor and 45 Proactor:
| the pump decision | covering tests |
|---|---|
| a deferring handler is requeued until rejected | When_a_command_handler_throws_a_defer_message_Then_message_is_requeued_until_rejected (×4) |
| the requeue-count threshold is reached | When_a_requeue_count_threshold_for_{commands,events}_has_been_reached (×4) |
ChannelFailureException retries until reconnected |
When_a_channel_failure_exception_is_thrown_…_should_retry_until_connection_re_established (×4) |
| the unacceptable-message limit | When_an_unacceptable_message_limit_is_{reached,reset} (×8) |
| reject falls back to the DLQ with no invalid channel | When_no_imq_configured_reject_falls_back_to_dlq (×2) |
Validity is compositional: the gateway suite proves the transport performs the operation, the pump suite proves the pump decides to perform it, and together they cover the behaviour. Neither needs to re-prove the other's half, and a gateway suite that re-drove the pump for all nine behaviours would be duplicating 86 tests across 24 configurations to learn nothing new.
FR-23 is the exception that shows where the line falls. Its question — does exhausting the budget put the message on the DLQ? — spans both halves at once: the budget exists only in the pump, and the landing is observable only on the transport. No test on either side alone can answer it, so FR-23 alone drives a real pump.
MessageDispatch tests above are deleted, weakened, or quietly narrowed,
every conformance cell in this ledger stays green while the composite claim silently stops being true
— the gateway suite cannot notice, because it never asserted the pump's half in the first place.
Treat those 86 tests as load-bearing for this ledger, not as an independent suite that happens to
exist.
PumpCoverageAudit is the link, and it fails the build. For each behaviour in the table above it
requires, in both the Reactor and the Proactor tree (FR-14 parity — one surviving half proves
half of what the ledger claims), a file whose name carries the behaviour's fragments and whose
contents still make at least one assertion. It reports the three losses separately, because the fix
differs: PumpBehaviourNotCovered (gone from both trees — a deletion), PumpBehaviourVariantNotCovered
(one pump kept it, the other dropped it), and PumpBehaviourCoverageHollowedOut (the name survives,
the assertions do not). Each rule was probe-verified red against the real tree, not argued.
PumpCoverageAudit.RequiredBehaviours, so adding a row to the table
above without adding it there leaves that row unguarded. Editing that list down to match a narrowed
tree silences the audit by construction — the list and this table are meant to be changed together,
and in review.
Evidence, run 2026-09-27 (spec 0037 task 4.11). Both variants (Reactor and Proactor) of every
AWS and AWS.V4 configuration, against the Floci emulator (floci/floci:1.5.19,
docker-compose-aws.yaml, port 4566; LocalStack is no longer used locally), net10.0,
--filter "LiveAWS!=true", the two projects run one after the other:
| Project | Passed | Failed | Skipped | FR-23 (Reactor + Proactor × 4 configurations) |
|---|---|---|---|---|
AWS.Tests |
286 | 0 | 2 | 8 / 8 pass |
AWS.V4.Tests |
286 | 0 | 2 | 8 / 8 pass |
The only skips are SqsFifo FR-9 (Reactor and Proactor) in each project, which is still Deferred -> #4240.
Every previously Pass/Fixed cell stayed green. The eight FR-23 tests per project are the
difference from the previous baseline (278 passed / 10 skipped).
What was wrong, for the record. Before #4341, SqsMessageConsumer.RequeueAsync requeued by
ChangeMessageVisibilityAsync, which never rewrites the stored message, and handled-count was
written only on send. Every redelivery therefore read 0, HandledCountReached(3) never fired, and
the message reached the DLQ only via the queue's maxReceiveCount redrive. It arrived with
handled-count=0 and no rejection metadata, so requeueCount was silently inert on SQS.
What fixed it (ADR 0077): both SQS message
creators read the broker's ApproximateReceiveCount on receive, minus 1, through the core
DeliveryCount.Resolve. The harness keeps the budget below the native limit (requeueCount: 3,
RedrivePolicy maxReceiveCount: 5, assumption A-5), so the pump rejects first. The message then
reaches the Brighter DLQ through Reject (ADR 0038's
direct send), carrying its stamped count and ADR 0036's rejection metadata. The count is
approximate (SQS's counter is), so FR-23 asserts it only as bounded, never as exact.
Evidence, run 2026-09-27, Floci, net10.0. The generated FR-23 test asserts only a bound on
HandledCount and rejectionReason, so a throwaway, uncommitted probe re-ran the generated FR-23
scenario unchanged for each configuration and variant. After the pump stopped, it recorded the
dead-lettered message's header and bag, plus every Warning-or-above log entry from a capturing
ApplicationLogging.LoggerFactory. It ran with collection parallelism off, so the logs are per test.
All 16 runs passed their FR-23 assertions.
| Configuration | Variant | Reason | Destination | Metadata key set | rejectionReason / rejectionMessage / originalMessageType |
Warning+ logs | Count (v3 / v4), R = 3 | Verdict |
|---|---|---|---|---|---|---|---|---|
| SnsStandard | Reactor | DeliveryError | Brighter DLQ | 13 keys, equal | equal | equal | 3 / 3 | same |
| SnsStandard | Proactor | DeliveryError | Brighter DLQ | 13 keys, equal | equal | equal | 3 / 3 | same |
| SnsFifo | Reactor | DeliveryError | Brighter DLQ | 14 keys, equal | equal | equal | 3 / 3 | same |
| SnsFifo | Proactor | DeliveryError | Brighter DLQ | 14 keys, equal | equal | equal | 3 / 3 | same |
| SqsStandard | Reactor | DeliveryError | Brighter DLQ | 13 keys, equal | equal | equal | 3 / 3 | same |
| SqsStandard | Proactor | DeliveryError | Brighter DLQ | 13 keys, equal | equal | equal | 3 / 3 | same |
| SqsFifo | Reactor | DeliveryError | Brighter DLQ | 14 keys, equal | equal | equal | 3 / 3 | same |
| SqsFifo | Proactor | DeliveryError | Brighter DLQ | 14 keys, equal | equal | equal | 3 / 3 | same |
- Values compared for equality, identical in every pair:
rejectionReason=DeliveryError;rejectionMessage=Handle count of messages reached; rejecting at limit;originalMessageType=MT_COMMAND; the legacyRejectionReasonsummary string;handled-count. - The key set:
handled-count,header1–header5(the test's own),originalMessageType,originalTopic,ReceiptHandle,rejectionMessage,rejectionReason,RejectionReason,rejectionTimestamp, plusmessageDeduplicationIdon the two FIFO configurations. - Compared for presence and shape only, all matching in every pair:
originalTopicequals the source topic; the destination is{topic}.DLQ({topic}-DLQon the wire,.fifokept on FIFO);rejectionTimestamphas the same format.ReceiptHandleandmessageDeduplicationIdare per-run values (a fresh GUID minted on the DLQ send, in both variants). - Warning+ logs, identical once per-run names and thread ids are removed: one pump Error
(
Have tried 3 times to handle this message … dropping message) and one pump Warning (Rejecting message …). Neither consumer logged a Warning. - Delivery count:
HandledCount= 3 in all 16 runs, which meets<= R. The pump dispatched 3 times in all 16 runs.
No divergence, so nothing to fix. Three things hold in both variants alike. They are not v3/v4 divergences, but they are recorded so they are not rediscovered:
rejectionTimestampis written in a culture-dependent format (27/09/2026 19:03:31), not ISO 8601.- The bag carries both the ADR 0036
rejectionReasonkey and the olderRejectionReasonsummary string. - On the SQS configurations the pump's log line reads
from <queue> with on thread, with an empty topic.
The RabbitMQ delayed-message-exchange plugin is being retired upstream, so the CI and compose pins moved
off brightercommand/rabbitmq:*-management-delay onto stock rabbitmq:4.2-management and
rabbitmq:3.13-management. The RabbitMQ versions are unchanged — only the plugin is gone.
The suite never used the plugin. The RMQ providers present a plain (non-delay) exchange, so
RmqMessageProducer reports DelaySupported == false and routes a non-zero delay to
IAmAMessageProducer.Scheduler — the same scheduler seam proven for Kafka, Redis and MSSQL. The native
x-delayed-message path is deliberately not exercised because it is not yet conformant (see the
RMQ.Async / Classic note above).
One test required the plugin and was retired:
tests/Paramore.Brighter.RMQ.Sync.Tests/MessagingGateway/Reactor/When_reading_a_delayed_message_via_the_messaging_gateway.cs
— the only supportDelay: true in the repository. Re-pointing it at the scheduler seam would have made it
a hand-written duplicate of generated coverage that already exists six times over
(When_sending_a_delayed_message_should_deliver_after_delay and
When_requeuing_a_failed_message_with_delay_should_redeliver_after_delay, across RMQ.Sync,
RMQ.Async / Classic and RMQ.Async / Quorum, Reactor and Proactor), so it was deleted instead. What is
no longer covered is ExchangeConfigurationHelper's SupportDelay branch, which is the path being retired.
Measured, not assumed. Against a stock, plugin-free broker (rabbitmq:4.2-management, RabbitMQ 4.2.9,
zero delayed-message plugins installed):
| suite | result |
|---|---|
Paramore.Brighter.RMQ.Sync.Tests |
81 passed, 3 skipped, 0 failed (84 total) |
Paramore.Brighter.RMQ.Async.Tests |
142 passed, 6 skipped, 0 failed (148 total) |
Both run under the CI filter Fragile!=CI&Requires!=Docker-mTLS. Every delay behaviour passes in both
variants. Before the retirement, the two facts in the deleted file failed on that broker in 1 ms with
PRECONDITION_FAILED - unknown exchange type 'x-delayed-message', which is what made them the whole
blocker to stock images.
brightercommand image runs as
root and leaves a root-owned, mode-400 .erlang.cookie in rabbitmq_data; the stock image runs as uid 999
and cannot read it, so the container crash-loops on
Error when reading /var/lib/rabbitmq/.erlang.cookie: eacces. Delete the volumes before the first run:
docker-compose -f docker-compose-rmq.yaml down
docker volume rm generator-transport-tests_rabbitmq_data generator-transport-tests_rabbitmq_logsCI is unaffected — GitHub Actions services: mount no volume.
Spec 0037 Phase 7A (tasks 7A.1, 7A.2) added two assertions to the five routing reject templates, Reactor and
Proactor: FR-4 (delivery_error_should_send_to_dlq), FR-5 (unacceptable_reason_should_send_to_invalid_channel),
FR-6 (unacceptable_and_no_invalid_channel_should_fallback_to_dlq), FR-8 (should_include_metadata) and FR-17
(unknown_reason_should_send_to_dlq). They now assert that:
- the copy read from the DLQ or invalid channel has the sent message's
Body.Value(7A.1,170ec0012); and Reject/RejectAsyncreturnedtrue(7A.2,487a9e554). FR-7'sno_channels_configuredtemplate already asserted this.
The deletion of the hand-written RocketMQ reject tests (ec2071bb0) had exposed both gaps. No ledger cell
changed. The new assertions passed wherever the old ones did.
⭐ Run record (2026-10-02, spec 0037 task 7A.3). All 17 projects with a test-configuration.json were
regenerated. The diff was 190 generated files in ten projects (AWS 40, AzureServiceBus 10, Gcp 40, Kafka 30, MQTT
10, MSSQL 10, PostgresSQL 10, Redis 10, RMQ.Async 20, RMQ.Sync 10), each +5/-1, all routing reject tests.
RocketMQ and AWS.V4 had already been regenerated in 7A.1/7A.2 and did not change again. The outbox-only projects
(DynamoDB, DynamoDB.V4, MongoDb, MySQL, Sqlite) generate no reject tests and did not change. The generated reject
tests (filter FullyQualifiedName~.WhenRejectingMessage, net10.0) were then run against local infrastructure:
| Project | Passed / Failed / Skipped | Infrastructure |
|---|---|---|
| AWS | 48 / 0 / 0 | Floci (LiveAWS!=true) |
| AWS.V4 | 48 / 0 / 0 | Floci (LiveAWS!=true); see the note below |
| Gcp | 40 / 0 / 8 | Pub/Sub emulator (Fragile!=CI); the 8 are FR-7 Deferred × 4 configurations × 2 variants |
| Kafka | 36 / 0 / 0 | docker-compose-kafka.yaml (RocketMQ stack stopped first: port 8081) |
| MQTT | 12 / 0 / 0 | docker-compose-mqtt.yaml |
| MSSQL | 12 / 0 / 0 | docker-compose-mssql.yaml |
| PostgresSQL | 12 / 0 / 0 | docker-compose-postgres.yaml |
| Redis | 12 / 0 / 0 | docker-compose-redis.yaml |
| RMQ.Async | 20 / 0 / 4 | docker-compose-rmq.yaml; the 4 are FR-5 Deferred × 2 configurations × 2 variants |
| RMQ.Sync | 10 / 0 / 2 | docker-compose-rmq.yaml; the 2 are FR-5 Deferred × 2 variants |
| RocketMQ | 12 / 0 / 0 | docker-compose-rocketmq.yaml, from down -v (100/100 topics) |
Not run locally: AzureServiceBus. It has no local emulator or compose file. Two of its 12 reject tests are
skipped by the ledger (FR-5 Deferred). The other ten compiled but did not run.
AWS.V4 note: the first AWS.V4 run went alongside the AWS run against the same Floci container and took
4 m 36 s. One test failed: SnsFifo Proactor unacceptable_reason_should_send_to_invalid_channel_async, after
2 m 48 s, with AmazonSQSException: … ReceiptHandle is invalid. Reason: The receipt handle has expired. That is
the visibility timeout lapsing under load, not a new assertion. Run alone, the same 48 tests passed in 1 m 22 s.
They had also passed in four earlier runs that day (7A.1 and 7A.2). Run the two AWS projects one after the other
against one Floci container.
RED under mutation (7A.1, 7A.2): RocketMQ (RocketMessageConsumer.RejectAsync) and SQS V4
(SqsMessageConsumer.RejectAsync) were mutated:
- Routed copy sent with body
"mutated-body": RocketMQ 10/10 and SQS V4 40/40 generated tests failed on the body assertion. return falseafter a successful route: the same counts failed on the return-value assertion.
An empty body is not a usable mutation. RocketMQ and Floci both refuse to send it, so nothing is routed and
the tests fail on arrival (MT_NONE) instead. On a RocketMQ store that has had a run on it, the DLQ read returns
a stale message, which can pass for a body-assertion failure.
Audited 2026-10-02 (spec 0037 task 8.6). Every cell below was checked against the matrix at
a4b30a329, compared with the merge base f906efc0b.
Moved by spec 0037: 33 cells, each citing its run.
| Cells | Moved to | Evidence (dated note in this ledger) |
|---|---|---|
FR-23 × the 8 AWS / AWS.V4 configurations |
Fixed (#4341) |
task 4.11, run 2026-09-27: 286 / 0 / 2 per project, FR-23 8 / 8 per project ("The eight AWS cells are Fixed (#4341)") |
FR-4, FR-5, FR-6, FR-8, FR-17 × the 4 GCP configurations (20) |
Fixed (#4386) |
task 5.11, 2026-09-28: 40 / 40 on a clean emulator. Repeated by task 6.31, 2026-10-01: 48 / 48 twice, with an empty per-test diff (GCP FR-23 section) |
FR-23 × the 4 GCP configurations |
Fixed (#4386) |
task 6.16, 2026-10-01: 8 / 8 on a clean emulator (GCP FR-23 section) |
FR-23 × RocketMQ |
Fixed (#4353) |
task 7.13, 2026-10-02: 2 / 2 on a clean store, 67 / 0 / 6 suite (RocketMQ FR-23 section) |
Also moved on this branch, but not by spec 0037: GCP / Stream and GCP / StreamOrdering FR-16 →
Fixed (#4449), by bugfix 0024 (2026-09-30). The evidence is in the Rules note for those two cells and in
bugfixes/0024-gcp-stream-nack-no-redelivery/bugfix.md. So the branch moves 35 cells in all.
Not moved by spec 0037: every remaining Deferred cell, 36 in all. Each moves only on its own evidence.
MQTTFR-23 (Deferred -> #4351): the Proactor pump deadlocks on the first requeue (see the MQTT note in the FR-23 section). Since moved, 2026-10-04:Fixed (#4441).AzureServiceBusFR-23, and its otherDeferredcells, FR-5 and FR-9: there is no local emulator, and Azure Service Bus dead-letters natively. Since moved, 2026-10-04: FR-9 →Pass(see the ASB FR-9 note).GCPFR-2, FR-7, FR-9, FR-15 and FR-22 on all four configurations, plus FR-16 onGCP / PullandGCP / PullOrdering(22 cells). Spec 0037 changed neither the requeue delay, the no-channels path, delayed send, nor PullNack.AWS / SqsFifoandAWS.V4 / SqsFifoFR-9: SQS FIFO queues refuse per-message delay.MSSQL,RedisandMQTTFR-16.RMQ.Async / Classic,RMQ.Async / QuorumandRMQ.SyncFR-5: there is no separate invalid-message channel.RocketMQFR-2 and FR-15.Requeueis still a broker no-op on the AC-24 branch. Task 7.1 found thatChangeInvisibleDuration(view, 0)works on client 5.2.1, so these two cells could now be made to pass, but that is outside spec 0037.
A-6 refutations from task 6.30: none. On all four GCP configurations and both variants, a message
whose rejection routing kept failing reached the native DeadLetterPolicy subscription in 12–17 s
(38c91413f). R-19's native-cap bound therefore held on the emulator, and no cell carries a refutation.
Revision: 5e7d78ef8. The last code change is 30828b1c7 (7A.3's reject regeneration); everything after it
is docs-only. Every test project with an in-scope or AC-26 configuration was run whole, net10.0, against local
infrastructure, one project at a time. Two counts are reported per project:
- Project: every test in the project.
- Generated: tests whose class is declared under a
Generated/folder. These are the conformance suites.
That scope differs from the 3.1–3.4 run record above, whose "generated suite" counts were taken with narrower filters. So the comparison below is made by test name, not by count.
| Group | Project | Infrastructure | Project P / F / S | Generated P / F / S, per configuration | FR-23 |
|---|---|---|---|---|---|
| AC-26 | Redis | docker-compose-redis.yaml |
71 / 0 / 4 | Redis 30 / 0 / 4 | 2 / 2 |
| AC-26 | Kafka | docker-compose-kafka.yaml (RocketMQ stopped: :8081) |
198 / 4 / 0, then the 4 re-run: 4 / 0 / 0 | Classic 39 / 1 / 0 → 40 / 0 / 0 on re-run; Consumer 38 / 0 / 0; PartitionKey 40 / 0 / 0 | 6 / 6 |
| AC-26 | MSSQL | docker-compose-mssql.yaml |
343 / 0 / 4 | MSSQL 34 / 0 / 4 (plus the outbox suites: Binary 31 / 0 / 0, Text 31 / 0 / 0) | 2 / 2 |
| AC-26 | PostgresSQL | docker-compose-postgres.yaml |
261 / 0 / 0 | Postgres 38 / 0 / 0 (plus the outbox suites: Binary 31 / 0 / 0, Text 31 / 0 / 0) | 2 / 2 |
| AC-26 | RMQ.Async | docker-compose-rmq.yaml |
145 / 9 / 6 | Classic 40 / 0 / 2; Quorum 40 / 0 / 2 | 4 / 4 |
| AC-26 | RMQ.Sync | docker-compose-rmq.yaml |
81 / 9 / 3 | RMQ.Sync 40 / 0 / 2 | 2 / 2 |
| in scope | AWS | Floci, LiveAWS!=true |
286 / 0 / 2 | SnsFifo 40 / 0 / 0; SnsStandard 40 / 0 / 0; SqsFifo 38 / 0 / 2; SqsStandard 40 / 0 / 0 | 8 / 8 |
| in scope | AWS.V4 | Floci, LiveAWS!=true, after the AWS run |
286 / 0 / 2 | SnsFifo 40 / 0 / 0; SnsStandard 40 / 0 / 0; SqsFifo 38 / 0 / 2; SqsStandard 40 / 0 / 0 | 8 / 8 |
| in scope | Gcp, CI filter | Pub/Sub emulator, from down -v |
155 / 50 / 30 | Pull 26 / 0 / 14; PullOrdering 26 / 0 / 14 | 4 / 4 |
| in scope | Gcp, stream filter | Pub/Sub emulator, from down -v |
122 / 0 / 25 | Stream 30 / 0 / 10; StreamOrdering 30 / 0 / 10 | 4 / 4 |
| in scope | RocketMQ | docker-compose-rocketmq.yaml, from down -v (100 / 100 topics) |
67 / 0 / 6 | RocketMQ 34 / 0 / 4 | 2 / 2 |
FR-23 is counted as Reactor + Proactor over the project's configurations. The GCP filters are
Category!=Spanner&Category!=GcpPubSubStream&Category!=GcpPubSubStreamOrdering&Fragile!=CI and
(Category=GcpPubSubStream|Category=GcpPubSubStreamOrdering)&Fragile!=CI. Every skipped generated test is a
Deferred cell emitted as Skip.
No conformance test failed, and no Pass or Fixed cell regressed. Each failure, by name:
- Kafka, 4: a cold broker. The suite started six seconds after
up -d. Three tests failed onChannelFailureException: Error finding topic …: generated Classic ProactorWhen_sending_a_message_should_propagate_activity_context_async, and the hand-writtenKafkaMessageConsumerFactoryDLQTests.When_creating_channel_with_dlq_subscription_should_pass_routing_keysandConsumerConfigHookTests.When_using_a_consumer_config_hook. The fourth was hand-writtenKafkaMessageConsumerUpdateOffsetAsync.When_a_message_is_acknowledged_update_offset, the arm that also failed in 3.x. Re-run on the warm broker, all four passed (4 / 0 / 0, 17 s). This timeschema-registrywas up, so the twoKafkaMessageProducerHeaderBytesSendTestsarms passed. - RMQ.Async 9, RMQ.Sync 9: no mTLS broker. These are the hand-written
Acceptance/*mtls*tests (RmqMutualTlsAcceptanceTests,RmqMutualTls[Quorum]Observability[Async]Tests). All are taggedRequires=Docker-mTLSand need anamqps://localhost:5671broker thatdocker-compose-rmq.yamldoes not provide. They fail onBrokerUnreachableExceptionorChannelFailureException, and CI excludes them (Requires!=Docker-mTLS). None is generated. - Gcp CI filter, 50: no real GCP. These are the Firestore outbox (30 generated) and inbox tests, plus the GCS luggage-store tests. That is the known baseline of 50, and no messaging test is among them.
Against the session baselines:
- AWS and AWS.V4: 286 / 0 / 2 each, as at 4.11 and
181abd588. - GCP CI filter: 155 / 30 / 50, as before.
- GCP stream filter: 122 / 25 / 0 against 121 / 25 / 0–1. The StreamOrdering two-message Nack flake did not fire.
- RocketMQ: 67 / 0 / 6, as at
181abd588. The FR-9 Proactor delay watch item (When_sending_a_delayed_message_should_deliver_after_delay_async) passed. - Reject tests: all 264 results from 7A.3's runs (the ten projects above, compared by test name) have the same outcome here. None is missing, and none changed from pass to fail or from skip to run. MQTT and AzureServiceBus were not run (they are outside 8.7's scope).
C-7 (ADR 0077, "First delivery on an approximate counter"): none observed. No first-delivery identity assertion failed in any run, on the exposed AWS, AWS.V4 and RocketMQ cells or anywhere else.
| Configuration | FR-2 | FR-4 | FR-5 | FR-6 | FR-7 | FR-8 | FR-9 | FR-15 | FR-16 | FR-17 | FR-22 | FR-23 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| AWS / SnsStandard | Pass | Pass | Pass | Pass | Pass | Pass | Fixed (#4240) | Pass | Pass | Pass | Pass | Fixed (#4341) |
| AWS / SnsFifo | Pass | Pass | Pass | Pass | Pass | Pass | Fixed (#4240) | Pass | Pass | Pass | Pass | Fixed (#4341) |
| AWS / SqsStandard | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Fixed (#4341) |
| AWS / SqsFifo | Pass | Pass | Pass | Pass | Pass | Pass | Fixed (7a0ec644f) | Pass | Pass | Pass | Pass | Fixed (#4341) |
| AWS.V4 / SnsStandard | Pass | Pass | Pass | Pass | Pass | Pass | Fixed (#4240) | Pass | Pass | Pass | Pass | Fixed (#4341) |
| AWS.V4 / SnsFifo | Pass | Pass | Pass | Pass | Pass | Pass | Fixed (#4240) | Pass | Pass | Pass | Pass | Fixed (#4341) |
| AWS.V4 / SqsStandard | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Fixed (#4341) |
| AWS.V4 / SqsFifo | Pass | Pass | Pass | Pass | Pass | Pass | Fixed (7a0ec644f) | Pass | Pass | Pass | Pass | Fixed (#4341) |
| GCP / Pull | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) | Fixed (#4386) | Fixed (#4386) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) | Deferred -> #4240 (sign-off: @iancooper) | Deferred -> #4240 (sign-off: @iancooper) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) |
| GCP / PullOrdering | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) | Fixed (#4386) | Fixed (#4386) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) | Deferred -> #4240 (sign-off: @iancooper) | Deferred -> #4240 (sign-off: @iancooper) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) |
| GCP / Stream | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) | Fixed (#4386) | Fixed (#4386) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) | Deferred -> #4240 (sign-off: @iancooper) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4449) | Fixed (#4386) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) |
| GCP / StreamOrdering | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) | Fixed (#4386) | Fixed (#4386) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) | Deferred -> #4240 (sign-off: @iancooper) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4449) | Fixed (#4386) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4386) |
| Kafka / Classic | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass |
| Kafka / Consumer | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass |
| Kafka / PartitionKey | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass |
| MSSQL / MSSQLMessagingGateway | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Deferred -> #4240 (sign-off: @iancooper) | Pass | Pass | Pass |
| PostgresSQL / PostgresMessagingGateway | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass |
| Redis / RedisMessagingGateway | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Deferred -> #4240 (sign-off: @iancooper) | Pass | Pass | Pass |
| RMQ.Async / Classic | Pass | Pass | Fixed (#4387) | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass |
| RMQ.Async / Quorum | Pass | Pass | Fixed (#4387) | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass |
| RocketMQ / RocketMQMessagingGateway | Fixed (#4353) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4353) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4353) |
| AzureServiceBus / AzureServiceBusMessagingGateway | Pass | Pass | Deferred -> #4240 (sign-off: @iancooper) | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Pass | Deferred -> #4240 (sign-off: @iancooper) |
| MQTT / MqttMessagingGateway | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Deferred -> #4240 (sign-off: @iancooper) | Fixed (#4240) | Fixed (#4240) | Fixed (#4441) |
| RMQ.Sync / RmqSyncMessagingGateway | Fixed (#4240) | Fixed (#4240) | Fixed (#4387) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Fixed (#4240) | Pass |
| RMQ.Async / NativeClassic | Fixed (#4388) | Pass | Pass | Pass | Pass | Pass | Fixed (#4388) | Pass | Pass | Pass | Pass | Pass |
| RMQ.Async / NativeQuorum | Fixed (#4388) | Pass | Pass | Pass | Pass | Pass | Fixed (#4388) | Pass | Pass | Pass | Pass | Pass |
| RMQ.Sync / NativeClassic | Fixed (#4388) | Pass | Pass | Pass | Pass | Pass | Fixed (#4388) | Pass | Pass | Pass | Pass | Pass |