Note: This document captures user requirements and needs. Technical design decisions and implementation details should be documented in an Architecture Decision Record (ADR) in
docs/adr/.
Linked Issue: #4341 (family head) · #4386 · #4353 · #4354
Numbering convention, read this first. Requirements in this document are numbered
R-n, and acceptance criteriaAC-n. They are deliberately not numberedFR-n, becauseFR-nnis already spoken for by spec 0036's conformance behaviour ledger (specs/0036-universal-transport-conformance-tests/conformance-status.md), which this document cites throughout. Throughout:R-n= a requirement of this spec;FR-nn= a conformance behaviour of spec 0036.
As an operator of a Brighter consumer, I would like a message whose handler keeps deferring to stop being redelivered after the number of attempts I configured, and to land somewhere I can inspect it, so that a poison message cannot loop for ever, consuming broker and compute budget, and so that I can see why it stopped.
Today that is true on nine of the conformance ledger's twenty-four configurations (Redis,
Kafka ×3, MSSQL, Postgres, RMQ.Async ×2, RMQ.Sync) and false on fifteen. Thirteen of the fifteen
are in scope here (AWS ×4, AWS.V4 ×4, GCP ×4, RocketMQ); the other two — MQTT and Azure Service
Bus — fail for different root causes and are named in §Out of Scope. On the thirteen,
requeueCount is accepted, configured, and has no effect. The pump enforces the budget by calling
message.Header.UpdateHandledCount() and then message.HandledCountReached(RequeueCount)
(Reactor.cs:494, :498; Proactor.cs:500, :504), which only runs down if the redelivered
message presents the incremented count. All nine that work requeue by republishing, so the
header travels and the budget runs down — the three RMQ rows included, which republish through
RmqMessagePublisher.RequeueMessageAsync and carry HANDLED_COUNT on the republished copy
(RmqMessagePublisher.cs:174, read back at RmqMessageCreator.cs:208-210). The RMQ rows differ
only in how the rejected message reaches its destination: Brighter's Reject is a
BasicRejectAsync(..., requeue: false) and RabbitMQ's DLX moves the broker's own stored copy,
rather than Brighter publishing to a destination it owns. The budget is exactly as load-bearing
there as on the other six — at requeueCount: -1 an RMQ message is requeued for ever and nothing
reaches the DLX. The thirteen that fail ask the broker to re-serve its own stored copy, which was never
rewritten — so every redelivery reads the count it was first published with, the pump bumps it to
1, and the test is never true.
As an operator of a GCP Pub/Sub consumer, I would like a rejected message to be preserved
somewhere, so that I can inspect or replay it. Today Reject acknowledges and discards it
in both the pull (GcpPullMessageConsumer.cs:276, async :306) and stream
(GcpPubSubStreamMessageConsumer.cs:84) consumers. The MessageRejectionReason argument is bound
and never read. RejectionReason.Unacceptable (a message a handler cannot parse) and
RejectionReason.DeliveryError (the budget spent) both destroy the message. This is silent data
loss on a production path, in all four configurations and both pump variants.
As an operator whose DLQ is provisioned by infrastructure-as-code, I would like to create a
DLQ-backed GCP channel with an application service account, so that my consumer starts.
Today GcpPubSubMessageGateway.EnsureSubscriptionExistsAsync (:235, :251) unconditionally reads
and writes project-level IAM and hard-fails if it cannot, even when the binding already exists.
As a Brighter maintainer, I would like one settled answer to how a delivery count survives a requeue the broker does not rewrite, so that the repo does not acquire three different answers to the same question, and so that the FR-23 conformance column stops being a list of the same defect three times.
Settle one delivery-count contract that every transport must honour, and then bind the three transports that do not honour it to it.
From a user's point of view, after this work:
requeueCountmeans what it says, everywhere. ConfigurerequeueCount: 3on an SQS, GCP or RocketMQ subscription and a message whose handler always defers is delivered a bounded number of times and is then rejected withRejectionReason.DeliveryError— the same observable outcome users already get on Redis, Kafka, MSSQL, Postgres and RabbitMQ.- A GCP
Rejectroutes the message rather than destroying it. A rejected GCP message goes to the dead-letter or invalid-message destination chosen by its rejection reason, carrying the rejection metadata every other Brighter-managed gateway stamps, before the original is acknowledged. - A DLQ-backed GCP channel can be created by a principal that cannot administer project IAM, and therefore against the Pub/Sub emulator — so the GCP half of this work has a local, repeatable proof rather than a cloud-only one.
- The relationship between a Brighter budget and a native redrive policy is stated, not discovered. When both are configured the effective limit is the smaller of the two, and Brighter says so at startup instead of letting a user believe a budget is in force that is not.
The users are Brighter application developers and the operators of their consumers. Nothing in their
configuration API needs to change: the same requeueCount, DeadLetterRoutingKey and
InvalidMessageRoutingKey they already set start having the effect they already expect.
This document does not choose between the mechanisms that remain open, but it does close one. "Read the broker's own delivery counter on receive" and "track the count consumer-side" are both capable of satisfying the contract below, at different costs on each transport, and choosing between them is the ADR's job. "Rewrite the stored message on requeue (republish)" is excluded, by NFR-3 and for the reasons C-12 records. §Constraints tabulates all three, the excluded one included, so the ADR starts from a complete set and the exclusion is visible rather than inferred.
Used with exactly these meanings throughout. Where a term names an existing code element, the element is cited so there is no second reading.
| Term | Meaning |
|---|---|
| Delivery | One presentation of a message to a Brighter consumer on a channel — i.e. one message returned from IAmAMessageConsumer.Receive/ReceiveAsync and dispatched by a pump. |
| Delivery count | The value of MessageHeader.HandledCount (MessageHeader.cs:226) on a message as it is handed to the pump by the consumer, before the pump calls UpdateHandledCount(). Where a rule reads the count on a message taken from a rejection destination rather than a source channel — R-5, R-28, AC-4, AC-41 — it means the HandledCount that message presents on that read; R-28 governs what that value must be. |
| Delivery budget | The Subscription.RequeueCount value. The maximum number of deliveries Brighter will make before rejecting with RejectionReason.DeliveryError. -1 (the default) means "no budget". |
| Budget exhaustion | The pump path at Reactor.cs:494-498 / Proactor.cs:500-504: UpdateHandledCount(), then HandledCountReached(RequeueCount) true, then RejectMessage(..., RejectionReason.DeliveryError). |
| Requeue | IAmAMessageConsumer.Requeue/RequeueAsync — returning a message to its channel for later redelivery. |
| Redelivery | Any delivery of a message after its first. |
| Native dead-lettering | The broker moves the message to a dead-letter destination by its own policy once a delivery threshold is crossed, with no Brighter call at all: SQS RedrivePolicy.maxReceiveCount, Pub/Sub DeadLetterPolicy.MaxDeliveryAttempts, Azure Service Bus $DeadLetterQueue. |
| Broker-routed rejection | Brighter calls Reject and the broker then moves its own stored copy to a destination Brighter never publishes to: RabbitMQ's DLX, reached by BasicRejectAsync(deliveryTag, requeue: false) (RmqMessageConsumer.cs:362). Distinct from native dead-lettering — Brighter's budget still has to run down to reach it — and distinct from Brighter-managed dead-lettering, because no Brighter-managed send occurs and therefore no rejection metadata is stamped. No transport in scope uses this route; it is defined because the three RMQ rows do, and R-22 protects them. |
| Native redrive limit | The delivery threshold of a native dead-letter policy. Written M below. |
| Brighter-managed dead-lettering | Brighter publishes the message to a destination it owns (IUseBrighterDeadLetterSupport.DeadLetterRoutingKey / IUseBrighterInvalidMessageSupport.InvalidMessageRoutingKey) and then removes the original. ADRs 0038-aws-sqs-dlq-direct-send, 0039-redis-dlq-brighter-managed, 0041-postgres-dlq-brighter-managed. |
| Rejection routing | Selecting the destination for a rejected message from its RejectionReason: Unacceptable → invalid-message destination, falling back to dead-letter when none is configured; DeliveryError and None → dead-letter. Reference implementation: SqsMessageConsumer.DetermineRejectionRoute (:541). |
| Rejection metadata | The Header.Bag keys a Brighter-managed rejection stamps: originalTopic, originalMessageType, rejectionReason, rejectionMessage, rejectionTimestamp. Reference: SqsMessageConsumer.RefreshMetadata (:496). |
| Exact counter | A delivery count that advances by exactly 1 per delivery. |
| Approximate counter | A delivery count the source documents as approximate or best-effort, and which may therefore advance by more than 1. SQS ApproximateReceiveCount and Pub/Sub delivery_attempt are both documented as such. |
| Transport in scope | One of: AWSSQS, AWSSQS.V4, GcpPubSub, RocketMQ. |
| Configuration | A row of spec 0036's Conformance Matrix, e.g. AWS / SqsFifo or GCP / StreamOrdering. |
| Variant | Reactor or Proactor. Spec 0036's FR-14 rule: a behaviour proven in one variant only does not count. Where an AC drives a consumer directly with no pump (e.g. AC-15 to AC-18, AC-42), a delivery is one message returned from Receive/ReceiveAsync, its delivery count is that message's HandledCount as returned, and "both variants" means the AC is run twice, paired — once making the synchronous form of every consumer call it makes, once the asynchronous form, sync with sync and async with async — Receive/ReceiveAsync, Reject/RejectAsync, Acknowledge/AcknowledgeAsync, and so on. |
Throughout, R denotes a subscription's RequeueCount and M its native redrive limit.
R-1. A redelivered message presents a delivery count strictly greater than the count it presented
on its previous delivery.
This holds for every transport in scope, on every channel, in both variants, whether the redelivery
follows an explicit Requeue or a lease/visibility/ack-deadline expiry.
Example (illustrative of the obligation above; it does not add to it). A message on an
AWS / SqsStandardsubscription is delivered, deferred, delivered again, deferred again, delivered a third time. The three deliveries present delivery counts c₁ < c₂ < c₃. Whether the increments are exactly 1 is settled by R-3, not here; whether the three deliveries arrive in any particular wall-clock spacing is not constrained.
R-2. The first delivery of a message presents a delivery count of 0.
This applies to a message that has never been requeued, whether or not a budget is configured, and
on all four transports in scope. R-2 is absolute and takes precedence over R-3: where a
transport's delivery count originates at 1, or reports an elevated value on a first delivery
because it is approximate, the consumer normalises it to 0 before handing the message to the
pump. Approximation is tolerated only from the second delivery onward.
Where a transport's counter is classified approximate (R-3), the ADR MUST state how its chosen
mechanism satisfies R-2 on a delivery whose broker counter exceeds its documented origin value; if
the mechanism cannot guarantee it, the ADR MUST record the residual risk against C-7's identity
assertion and name the conformance cells exposed to it. Silence on this point is not an available
outcome.
Example. A message is published to
GCP / Streamand delivered once. The consumer hands the pump a message whoseHeader.HandledCountis0, equal to the value the producer sent.
Why this is an obligation and not an implementation nicety: the generated conformance assertion
DefaultMessageAssertioncomparesexpected.Header.HandledCountwithactual.Header.HandledCountfor equality (tools/Paramore.Brighter.Test.Generator/Templates/DefaultMessageAssertion.cs.liquid:59). A first delivery that presented1would fail the round-trip identity assertion used by conformance behaviours FR-2, FR-15, FR-16, FR-22 and others across every configuration of the transports in scope. See R-23.
R-3. For n >= 2: where a transport's delivery count is exact, the nth delivery presents a
count of n - 1; where it is approximate, the count presented is at least n - 1.
R-3 governs redeliveries only. At n == 1, R-2's exact 0 governs on every transport in scope,
exact or approximate, and R-3 grants no latitude there.
The ADR must record, for each of the four transports in scope, which of the two classifications
applies and on what evidence, as a four-row table. A transport may not be left unclassified, because
R-11's validation rule and AC-11's Given both read that classification.
Example (exact). Deliveries 1, 2, 3 of a message present counts
0,1,2. Example (approximate). Deliveries 1, 2, 3 of the same message may present0,1,3— R-1 and R-3 are both satisfied; R-5's bound is still met.
R-4. A delivery budget of R (where R >= 1) causes a message whose handler defers on every
delivery to be rejected with RejectionReason.DeliveryError after at most R deliveries, and never
to be redelivered a further time.
"Never redelivered a further time" means: the handler is not invoked again for that message, and
the count of handler invocations for it stands at R or fewer when the FR-23 observation window
closes. That window closes when the pump has been quit and awaited — which the FR-23 template
already does, two statements after its dead-letter poll breaks
(_channel.Enqueue(MessageFactory.CreateQuitMessage(...)); await pumping;). The poll itself — every
500 ms, giving up at 60 s — is not the close: between the poll's break and the quit the pump is
still dispatching, which is precisely the interval in which a budget that did not hold would keep
redelivering, so closing the window at the break would blind the observation to the failure it
exists to catch. No second observation window is opened and no additional wait is introduced — the
quit and await are already there — so the behaviour's runtime is unchanged by this spec.
One exception, owned by R-19. When the rejection that spends the budget cannot be routed —
its routing publish fails — R-19 releases the message instead of acknowledging it, so it is
redelivered and the handler is invoked again. "Never redelivered a further time" does not hold on
that path; what bounds it there is the native cap R-19 names where a DeadLetterPolicy is
configured and able to forward, and nothing where it is not (R-19) — never the budget. AC-3's run configures a working
dead-letter destination, so the exception does not arise inside it; AC-43 covers it.
Example.
requeueCount: 3onAWS / SqsStandard, handler always defers, no native redrive policy in force ahead of it. Delivery 1 presents0, pump increments to1,1 >= 3false, requeue. Delivery 2 presents1, increments to2, false, requeue. Delivery 3 presents2, increments to3,3 >= 3true →RejectMessage(..., DeliveryError). Three deliveries, two requeues, one rejection. For an approximate counter that jumped0,2, the rejection occurs on delivery 2 — still "at mostR", which is what R-4 requires.
R-5. The rejection raised by budget exhaustion is routed by the transport's rejection routing and
carries rejection metadata.
DeliveryError routes to the dead-letter destination. The message arriving there carries the five
rejection-metadata keys of the transport, and a delivery count of at least R - 1.
Example.
requeueCount: 3,deadLetterRoutingKeyconfigured. The message arrives on the dead-letter destination withrejectionReason = "DeliveryError", a non-emptyrejectionMessage, arejectionTimestamp, anoriginalTopicequal to the source topic, anoriginalMessageType, andHandledCount >= 2.The
>= R - 1bound (not== R) is the bound spec 0036's FR-23 template already asserts, and it is the strongest bound true of both routes to the DLQ: where Brighter sends the message it sends the final in-memory header and the count reads at leastR— exactlyRwhere the counter is exact, more where an approximate counter jumped pastR - 1on the rejecting delivery; where the broker moves its own stored copy the copy was written by the last requeue and reads one less. R-28 is what makes this bound survive the mechanism: it forbids a read of the dead-letter destination from reporting that destination's own delivery count instead.
R-6. A delivery budget of -1 disables budget enforcement, and this behaviour does not change.
With RequeueCount == -1 (the Subscription default, Subscription.cs:203), no delivery count is
ever compared against a budget, no DeliveryError rejection is raised by the pump, and a deferring
handler's message is requeued indefinitely, absent a native redrive limit (R-8). R-1, R-2 and R-3 still hold — the count still advances —
but nothing acts on it.
Example.
requeueCountleft at its default onRocketMQ / RocketMQMessagingGateway, handler always defers.DiscardRequeuedMessagesEnabled()(MessagePump.cs:171) is false, the message is redelivered for as long as the pump runs, and nothing reaches the dead-letter destination.
R-7. A delivery budget of 1 rejects on the first delivery; a budget of 0, and any budget
below -1, rejects on the first delivery and is surfaced by startup pipeline validation as a
probable misconfiguration.
R == 1: the first deferral exhausts the budget (count 0, incremented to 1, 1 >= 1), so the
message is rejected without ever being requeued. R == 0: enforcement is enabled (0 != -1) and
the first deferral also rejects immediately. R < -1 (e.g. -3): enforcement is likewise enabled,
because the pump's switch is RequeueCount != -1 (MessagePump.cs:171) and not a sign test, and
the first deferral rejects immediately for the same reason — HandledCountReached compares
HandledCount >= requeueCount (Message.cs:161-164), and 1 >= -3 is true. -1 is therefore the
only value that disables the budget; every other negative value behaves as 0 does.
Because a zero or below--1 budget is indistinguishable in effect from 1 and is far more likely
to be a mistake than an intent, both are reported by a ValidatePipelines check (R-25) at
ValidationSeverity.Warning, naming the subscription, the configured value, and the two values
probably intended — -1 (unlimited) or 1 (once only). None of these values is a configuration
error, none throws, and a Warning never blocks startup.
Example.
requeueCount: 1onAWS.V4 / SqsFifo, handler defers once. One delivery, zero requeues, oneDeliveryErrorrejection, message on the DLQ withHandledCount >= 0.
R-28. A delivery count read from a rejection destination is the count stamped by whatever routed the message there, never that destination's own delivery count. R-2's normalisation is a property of the delivery a pump consumes. It must not restate the count of a message read back from a dead-letter or invalid-message destination: that count is evidence of what happened on the source channel, and R-5, AC-4 and spec 0036's FR-23 template all read it as such. A message read from a rejection destination therefore presents:
- the count the rejecting consumer sent — the final in-memory header, at least
Rand exactlyRwhere the counter is exact — where Brighter published it there (Brighter-managed dead-lettering); - the count the last requeue wrote —
R - 1— where the broker moved its own stored copy on Brighter's reject (broker-routed rejection); - whatever the last write left it, where a native policy moved it with no Brighter call (native dead-lettering: R-9's case, which carries no rejection metadata and which R-28 does not bound).
R-5's >= R - 1 bound is exactly the envelope of the first two, which is why R-28 is what keeps R-5
assertable.
Why this is an obligation and not an implementation nicety: every conformance read of a rejection destination goes through the production consumer, so a mechanism that synthesises the count on receive synthesises it there too.
SqsStandardMessageGatewayProvider.GetMessageFromDeadLetterQueueAsyncbuilds a real channel throughChannelFactory(:294), the GCP provider does the same (GcpPullMessageGatewayProvider.cs:298), and the RocketMQ provider wraps itsSimpleConsumerin a realRocketMessageConsumer(:298). A mechanism that simply substituted the broker's counter for the stamped header would report the rejection destination's delivery count —0after R-2's normalisation — falsifying R-5, AC-4 and the FR-23 template's existingHandledCount >= RequeueCount - 1assertion on every cell this spec moves. A user inspecting their own DLQ with a Brighter consumer would lose the same evidence. C-12 makes this the ordinary case rather than a corner: with republish excluded, a synthesised count is the likely mechanism.
The ADR MUST record, for each of the four transports in scope, how its chosen mechanism satisfies R-28, as a four-row table alongside R-3's. Where it satisfies R-28 by telling a routed message apart from a source-channel delivery, the ADR MUST name the discriminator. Silence on this point is not an available outcome.
Example.
requeueCount: 3onAWS / SqsStandard, budget exhausted, Brighter sends the message to its dead-letter queue. A read of that queue presentsHandledCount >= 3—3unless the approximateApproximateReceiveCountjumped (A-4), and never the0that the dead-letter queue's ownApproximateReceiveCountof1would normalise to. Example (R-9's case).requeueCount: 10,maxReceiveCount: 3. SQS redrives its own stored copy with no Brighter call, so the redrive target presents the count that copy was published with and no rejection metadata. R-28 does not require that to be>= R - 1, and AC-9 asserts no count.
R-8. When both a delivery budget R and a native redrive limit M are configured on the same
channel, the effective delivery limit is min(R, M), and which of the two fired is observable.
Brighter does not suppress, reconfigure or work around a native policy. Whichever threshold is
reached first determines the route the message takes, and R-9 makes the two routes distinguishable.
Example (Brighter wins). SQS
requeueCount: 3,RedrivePolicy.maxReceiveCount: 5. The budget is exhausted on delivery 3 (or earlier on an approximate counter, R-4), Brighter rejects, and the message reaches the Brighter-managed DLQ with rejection metadata. SQS's own counter never reaches 5. Example (native wins). SQSrequeueCount: 10,maxReceiveCount: 3. SQS redrives its stored copy on the 3rd receive. The budget, which needed 10, is never spent. Example (tie).requeueCount: 3,maxReceiveCount: 3— the two thresholds coincide and which fires is not determined by Brighter. R-10 requires this to be warned about, not resolved.
R-9. A message dead-lettered by a native redrive policy without Brighter having rejected it arrives at the dead-letter destination WITHOUT Brighter rejection metadata, and Brighter MUST NOT fabricate metadata for a route it did not take. The absence of rejection metadata is the evidence that Brighter did not reject the message — it is how #4341 was detected — and that diagnostic must be preserved. This is a decision, recorded here so it is not left implicit: metadata absence on a natively dead-lettered message is permitted and intended, not a defect.
Example. SQS
requeueCount: 10,maxReceiveCount: 3, handler always defers. The message arrives on the redrive target queue with its originalhandled-countattribute and with norejectionReason,rejectionMessage,rejectionTimestampororiginalTopickeys. A DLQ consumer reading it can conclude from that absence alone that a native policy, not Brighter, moved it.
R-10. Startup pipeline validation reports a Warning when a configured delivery budget cannot be
relied on to fire ahead of a native redrive limit — that is, when R != -1 and M is configured
and R >= M.
Reported by a ValidatePipelines check (R-25). The finding names the subscription, R and M, and
states that the effective limit is M. It is a Warning, never an exception: the configuration is
legal and may be deliberate, and a Warning does not block startup even under throwOnError: true.
Example.
GCP / PullwithrequeueCount: 5andDeadLetterPolicy.MaxDeliveryAttempts: 5— the harness configuration as it stands before R-27 (GcpPullMessageGatewayProvider.cs:151,155) — is reported. So isAWS / SqsStandardwithrequeueCount: 3andRedrivePolicy(dlq, 3)(SqsStandardMessageGatewayProvider.cs:104,108). Both are real configurations in this repo today; they are harmless only because the budget is currently inert, they start racing the moment it works (A-5), and R-27 removes both — after it, a subscription in this state has to be constructed by the test that wants one.
R-11. A delivery budget configured on a transport that cannot satisfy R-1 for that subscription is
reported by startup pipeline validation, and additionally logged once at channel creation.
This is the anti-silence requirement: a budget that cannot run down must never be merely inert. The
finding names the subscription, the configured R, and the reason the count cannot advance. It is
raised once per channel, never per message. R-26 explains why this one condition is reported by both
routes while R-7 and R-10 use validation alone.
Example. A
GcpPubSubSubscriptionwithrequeueCount: 3and noDeadLetterpolicy, if the ADR's chosen mechanism is one whose counter Pub/Sub only populates on DLQ-backed subscriptions (see Assumption A-1): channel creation logs a Warning saying the budget is not enforceable on that subscription. Example. ARocketMqSubscriptionwithrequeueCount: 3, should R-14's condition fail: channel creation logs a Warning citing the blocker.
R-12. Paramore.Brighter.MessagingGateway.AWSSQS and Paramore.Brighter.MessagingGateway.AWSSQS.V4
satisfy R-1 to R-5 in lockstep, across all eight AWS and AWS.V4 configurations and both
variants.
"Lockstep" is ADR 0038-aws-sqs-dlq-direct-send's requirement and means identical observable behaviour: the same contract,
the same routing, the same metadata keys, the same log messages. The eight configurations are
AWS / SnsStandard, AWS / SnsFifo, AWS / SqsStandard, AWS / SqsFifo and the four AWS.V4
twins. FIFO queues are included: nothing in R-1 to R-5 permits a FIFO channel to be exempted.
Example.
requeueCount: 3onAWS.V4 / SnsFifo, handler always defers. Three deliveries (or fewer on an approximate counter, R-4) presenting counts0,1,2(or an approximate sequence satisfying R-3), aDeliveryErrorrejection, and a dead-lettered message withrejectionReason = "DeliveryError"— observed in both theReactorandProactorvariants.
R-13. Paramore.Brighter.MessagingGateway.GcpPubSub satisfies R-1 to R-5 in both the pull and the
stream consumer, across all four GCP configurations and both variants.
The four configurations are GCP / Pull, GCP / PullOrdering, GCP / Stream,
GCP / StreamOrdering. Both consumer classes are bound: GcpPullMessageConsumer and
GcpPubSubStreamMessageConsumer. An ordering-key subscription is not exempted.
R-13 is conditional in the same shape R-14 is, on the ADR's recorded conclusion — to which assumption A-2 supplies one route — and both sides have a defined "done". A-2 — that the Pub/Sub emulator populates the delivery counter on a DLQ-backed subscription — cannot be measured until R-20 lands, and R-21 makes the emulator the verification bar.
- If the ADR records a GCP mechanism that satisfies R-1 to R-5 within NFR-1 to NFR-3 — which
A-2 holding makes available through the broker counter, and which a
delivery_attempt-independent mechanism can also supply — "done" = the fourGCP / *FR-23 cells move toFixed, both variants, on emulator evidence. A-2 holding is what makes the counter available; it is the ADR's recorded conclusion, not the measurement alone, that selects this branch (see the note under AC-40). - If the ADR records that no GCP mechanism satisfies R-1 to R-5 within NFR-1 to NFR-3 — whether
because A-2 was refuted and no
delivery_attempt-independent mechanism fits, or because A-2 held but no mechanism reaches R-2's exact0or R-28's rule — GCP is bound but unimplemented on R-1 to R-5, and "done" = (a) R-11's Warning fires for anyGcpPubSubSubscriptionwithRequeueCount != -1; (b) the fourGCP / *FR-23 cells stayDeferred, re-pointed at the emulator limitation rather than at #4240; (c) the measurement that settled it is written intoconformance-status.md's GCP paragraph.
A-2 shapes the route, not the branch. If A-2 is refuted, R-13's obligation is unchanged but the
broker counter is unavailable, so the ADR must look for a GCP mechanism that does not depend on
delivery_attempt before it may record the second branch.
The GCP stream consumer's expiry redelivery is an ADR input, not a requirements decision. R-1
binds GcpPubSubStreamMessageConsumer's expiry path as it binds every other, and AC-42 asserts it;
how a test drives a lease lapse through that consumer is left to the ADR, because it turns on
client-library behaviour this document should not fix. Facts verified in source, which the ADR
starts from:
- While a received message is unanswered,
BrighterStreamHandler.HandleMessageawaitsWaitForCompleteAsync()(GcpStreamConsumer.cs:71-84) andSubscriberClientkeeps extending its lease — by default for up to 60 minutes (DefaultMaxTotalAckExtension, Google.Cloud.PubSub.V1 3.36.0). The subscription'sAckDeadlineSecondsdoes not govern that. AMaxTotalAckExtensionset throughGcpPubSubSubscription.StreamingConfigurationsurvives the factory, because the hook runs beforebuilder.Settings ??= …(GcpPubSubConsumerFactory.cs:110-121). - The client's concurrent-processing limit is
BufferSize × NoOfPerformers(GcpPubSubConsumerFactory.cs:88-91),1by default (Subscription.cs:200-201), applied client-wide, andStreamingConfigurationcannot raise it because the factory overwritesFlowControlSettingsafter the hook. A held, unanswered delivery occupies that slot, so at the default a redelivery is never dispatched while the first is held. - On
GCP / StreamOrderingthe redelivery shares the held message's ordering key, and ordered delivery may dispatch same-key messages only sequentially — not verified. - Shutdown uses
ShutdownMode.WaitForProcessingwith noTimeout(GcpStreamConsumer.cs:49), which the library bounds by a default derived from the maximum extension duration.
On R-13's first branch (the ADR records a satisfying GCP mechanism; AC-19 claimed), the ADR MUST
record, for each of GCP / Stream and GCP / StreamOrdering, the subscription and client
configuration and the receive / complete sequence by which AC-42 is driven — or, where a
configuration cannot be driven that way, an alternative test of R-1's expiry clause for it, and
why. An alternative test must itself be executed and assertable: it runs against the Pub/Sub
emulator (R-21), in both variants (NFR-8), through GcpPubSubStreamMessageConsumer, and asserts that
a redelivery that follows a lease lapse presents a strictly greater count — a redelivery for which
no Nack, no ModifyAckDeadline and no acknowledgement was issued, whether by Requeue, by Reject
(R-19's release is a Nack) or directly. An argument is not
evidence — in particular, that the stream consumer shares code with the pull consumer does not
evidence the stream consumer. Neither configuration may be left without one of the two. On R-13's
second branch (AC-40) AC-42 does not apply to GCP (see the guard at the head of §Delivery-count
contract), and this obligation lapses with it.
R-15 to R-19 (rejection routing) do not depend on A-2 and are unaffected by either branch. They depend on R-20 alone, which is why the twenty GCP rejection-routing cells in AC-30 are unconditional while the four GCP FR-23 cells are not.
Example.
requeueCount: 3and aDeadLetterPolicywithMaxDeliveryAttempts: 5onGCP / StreamOrdering, handler always defers. The budget is exhausted on delivery 3 (or earlier on an approximate counter, R-4) — ahead of Pub/Sub's own threshold of 5 — and the message reaches the Brighter dead-letter destination withrejectionReason = "DeliveryError".
R-14. RocketMQ is bound by the contract unconditionally; whether it is implemented here is conditional, and both sides of the branch have a defined "done".
The condition, stated so it can be tested: a RocketMQ message redelivered after its invisibility lease lapses presents a broker-supplied delivery-attempt value strictly greater than the value it presented on the previous delivery, without any call to
consumer.ChangeInvisibleDuration.
- If the condition holds — RocketMQ satisfies R-1 to R-5 in this spec, in both variants.
RocketMessageConsumer.Requeue(:179) may remain a no-op with respect to the broker; nothing in R-1 to R-5 requires a broker call on requeue. "Done" = theRocketMQ / RocketMQMessagingGatewayFR-23 cell moves toFixed, both variants green againstdocker-compose-rocketmq.yaml. - If the condition does not hold — RocketMQ is bound but unimplemented. "Done" = (a)
RocketMqSubscriptioncontinues to implement both support interfaces, which it already does; (b) R-11's Warning fires for any RocketMQ subscription withRequeueCount != -1; (c) the FR-23 cell staysDeferredwith its pointer changed from #4353 to the specific upstreamChangeInvisibleDurationblocker, and #4353 is updated to record the measurement that settled the branch; (d) the measurement — inputs, counts observed on each delivery, and the conclusion — is written intoconformance-status.md's RocketMQ paragraph so it is not re-derived.
Example of measuring the condition.
requeueCount: 3onRocketMQ / RocketMQMessagingGateway, handler always defers,Reactorvariant. RocketMQ's invisibility lease is 10 s, so three deliveries occupy roughly 30 s, inside the FR-23 template's 60 s poll ceiling. Log the broker's delivery-attempt value on each of the three deliveries. Values1, 2, 3(or any strictly increasing sequence) satisfy the condition; values1, 1, 1do not.
R-15. GcpPubSubSubscription offers a dead-letter destination and an invalid-message destination
in the same way every other Brighter-managed gateway does.
It implements IUseBrighterDeadLetterSupport and IUseBrighterInvalidMessageSupport, joining the
eight transport gateways plus InMemory that already do
(AWSSQS, AWSSQS.V4, Kafka, MQTT, MsSql, Postgres, Redis, RocketMQ, InMemory).
This is additive: GcpPubSubSubscription keeps its existing DeadLetter (DeadLetterPolicy)
property, which configures Pub/Sub's native policy and is a different thing from
DeadLetterRoutingKey.
Example.
new GcpPubSubSubscription<MyCommand>(..., deadLetterRoutingKey: new RoutingKey("orders.DLQ"), invalidMessageRoutingKey: new RoutingKey("orders.Invalid"))compiles and both values are readable through the two interfaces.
R-16. A GCP Reject routes the message to the destination selected by its rejection reason before
acknowledging the original, and never discards a message for which a destination is configured.
Routing follows the settled rejection-routing rules: Unacceptable → invalid-message destination,
falling back to the dead-letter destination when no invalid-message destination is configured;
DeliveryError and None → dead-letter destination. Order matters: the publish to the destination
precedes the acknowledgement of the original, so a failed publish cannot lose the message
silently. Both GcpPullMessageConsumer.Reject/RejectAsync and
GcpPubSubStreamMessageConsumer.Reject/RejectAsync are bound.
If the publish succeeds and the acknowledgement of the original then fails, the message is not
lost and the consumer keeps consuming. The failure is logged at Error naming the message id, and
Reject returns true without throwing. The original stays leased until its ack deadline lapses. It
may then be redelivered (a failed acknowledgement may still have been applied on the server), and,
when rejected again, it is routed again. This is the second accepted case in which a message is left
outstanding, beside R-19's failed release. Like that case, it can arise only on
GcpPullMessageConsumer, because the stream consumer's acknowledgement is local and cannot fail.
The destination may then hold a duplicate. That is accepted: delivery to the destination is
at-least-once. How Reject achieves this is part of R-19's ADR input.
Example (
Unacceptable, both destinations configured).Reject(message, new MessageRejectionReason(RejectionReason.Unacceptable, "could not deserialize"))onGCP / Pullpublishes the message toorders.Invalid, then acknowledges the original. A read oforders.Invalidreturns it; a read oforders.DLQreturnsMT_NONE. Example (Unacceptable, dead-letter only). Same call with no invalid-message destination configured publishes toorders.DLQ, then acknowledges. Example (DeliveryError).Reject(message, new MessageRejectionReason(RejectionReason.DeliveryError, "Handle count of messages reached"))publishes toorders.DLQ, then acknowledges. Example (None).Reject(message, new MessageRejectionReason(RejectionReason.None))publishes toorders.DLQ, then acknowledges.
R-17. A GCP Reject with no destination configured acknowledges the message and logs that fact at
Warning, naming the message id and the rejection reason.
This is the one case in which a rejected GCP message is still discarded, and it must not be silent.
If that acknowledgement fails, the outcome is R-16's: the failure is logged at Error naming the
message id, it does not escape Reject, and the message is left to its ack deadline. This is the
same accepted outstanding case, and it arises on GcpPullMessageConsumer only.
Example.
Reject(message, new MessageRejectionReason(RejectionReason.Unacceptable, "bad payload"))on a subscription with neitherDeadLetterRoutingKeynorInvalidMessageRoutingKeylogs a Warning containing the message id and"Unacceptable", then acknowledges.
R-18. A message routed by a GCP Reject carries the five rejection-metadata keys.
originalTopic = the source topic; originalMessageType = the message type header as it was on
receive; rejectionReason = the enum name; rejectionMessage = the description, when a non-empty
description was supplied; rejectionTimestamp = an ISO-8601 round-trip ("o") UTC timestamp. The
GCP-specific receipt handle is removed from the routed copy, so it does not carry a stale ack
id. Removing it must not prevent the original's acknowledgement under R-16 or its release under R-19
(see R-19's ADR input). Key names match the transport's existing casing convention.
Example. A message rejected with
new MessageRejectionReason(RejectionReason.DeliveryError, "Handle count of messages reached; rejecting at limit")from topicordersarrives onorders.DLQwithoriginalTopic = "orders",rejectionReason = "DeliveryError",rejectionMessage = "Handle count of messages reached; rejecting at limit", a parseablerejectionTimestamp, anoriginalMessageType, and noReceiptHandlekey.
R-19. If the routing publish fails, the original message is not acknowledged and the failure is logged at Error. The message therefore becomes eligible for redelivery rather than being destroyed. GCP has no incumbent behaviour on this path (today it acks and discards unconditionally), so the choice here is a greenfield one, and it is made in favour of preserving the message. The observable outcomes:
- An Error is logged naming the message id and the rejection reason.
- The message is released for prompt redelivery, by the same call that consumer's
Requeuealready makes, so the path replaces the acknowledgement and adds no broker call (NFR-3). "Prompt" means that the release, not an ack deadline or a lease, makes the message eligible again. From then on its redelivery is governed only by the subscription's retry policy: immediate at the defaultrequeueDelay: 0, and otherwise after that policy's back-off. The message is never left outstanding. On the stream consumer that would stall the whole subscription (the facts are recorded under R-13). Rejectreturnstrue, meaning "settled by this call", so the pump does not go on to acknowledge, and so discard, the message.- If the release itself fails, that is also logged at Error,
Rejectstill returnstrue, and the message returns when its ack deadline lapses. It is the one case in which R-19 leaves a message outstanding (a failed acknowledgement under R-16 or R-17 is the other accepted case), and it can arise only onGcpPullMessageConsumer.
The competing risk is a loop, and the delivery budget does not bound it. The budget ends a
message's life with a Reject (R-4), which is the very call whose publish is failing, and every
other rejection the pump issues never consults the budget. A message whose routing publish keeps
failing is therefore released and redelivered again and again (R-4's exception).
What bounds it is Pub/Sub's own DeadLetterPolicy.MaxDeliveryAttempts (M), which counts
every delivery attempt, releases included. After M the service forwards the message to the
policy's dead-letter topic without Brighter's rejection metadata, which is R-9's route. That is the
only bound this spec relies on; on the emulator it is assumption A-6, and AC-43 asserts it.
Where no policy can take effect, there is no bound. That covers a subscription with no
DeadLetterPolicy. It also covers one whose forwarding permissions R-20 tolerated the absence of
and nothing else provisioned; R-8's and R-9's native route are then inactive too, and R-20's
Warning names that consequence. A routing publish that fails deterministically (a missing topic, a
denied permission) then loops for as long as it fails, logging this requirement's Error on each
turn. A non-zero requeueDelay slows the loop but does not bound it. Both cases are accepted,
explicitly, as the price of never discarding a message whose destination is configured. The
emulator implements no IAM, so the second case does not arise on R-21's local bar, and A-6 is
unaffected by it.
SqsMessageConsumer.RejectAsync used to take the opposite branch — on a failed DLQ send it deleted
the source, which its own code comment recorded as deliberate infinite-loop avoidance: SQS accepted
a bounded loss where GCP accepts a retry that only a DeadLetterPolicy bounds. That behaviour
predated this spec and was left unchanged here; whether it should change was recorded as an open
question for a separate issue (see former §Out of Scope entry). That question is now answered:
issue #4415 (PR #4523, merged 2026-10-05) changed SQS to match this requirement — on a failed
DLQ/invalid-message send it now releases the source for redelivery (ChangeMessageVisibility(…, 0)
via a new ReleaseSourceMessageAsync helper, V3 SqsMessageConsumer.cs:311-323, V4 lockstep per
ADR 0038) instead of deleting it, logs at Error, and still returns true. The two transports no
longer differ on this path, on the same accepted trade-off: SQS has no runtime visibility into
whether the actual queue carries a RedrivePolicy (only what Brighter itself configured when it
created the queue), so a release can loop as long as a deterministic failure persists, bounded only
by the queue's MessageRetentionPeriod if no redrive policy is in effect — see the ADR 0038
amendment this change required. One path SQS does not yet close: a producer-creation failure
(as opposed to a publish failure) for the DLQ/invalid-message channel still falls through to delete
(bugfixes/0048-sqs-reject-deletes-on-dlq-failure/bugfix.md, Scope Notes Risk 2) — tracked as a
follow-up, not resolved by #4415.
Example.
deadLetterRoutingKeynames a topic that does not exist and that the dead-letter producer does not create.Reject(message, new MessageRejectionReason(RejectionReason.DeliveryError, "budget spent"))logs an Error naming the message id and the reason, releases the message with the consumer's requeue call instead of acknowledging it, and returnstrue. AtrequeueDelay: 0the message is redelivered at once — not after its ack deadline lapses, and on the stream consumer without holding the subscription's flow-control slot. With aDeadLetterPolicyofMaxDeliveryAttempts: 5, Pub/Sub forwards it to the policy's topic after its fifth attempt.
How a GCP Reject fails safely is an ADR input, not a requirements decision. R-16, R-18 and
R-19 fix the outcomes. The code paths that produce them are the ADR's to choose. Facts verified in
source, which the ADR starts from:
-
The release calls. The pull
RequeueissuesModifyAckDeadline(…, 0)(GcpPullMessageConsumer.cs:349, async:384). The streamRequeueNacks viaGcpStreamMessage.Reject()(GcpPubSubStreamMessageConsumer.cs:217-224), which completes a local reply and throws nothing (GcpStreamConsumer.cs:133-135). Only the pull release can fail. -
The pump acknowledges on
false.RequeueMessagereturnsReject's result, andfalsefalls through to the acknowledgement (Reactor.cs:320→:367;Proactor.cs:354→:402).truemeans "settled by this call", as on SQS's failure path (SqsMessageConsumer.cs:311-323). -
Reusing
Requeueas-is is a trap.- The pull
Requeuecatches every exception and returnsfalse(GcpPullMessageConsumer.cs:354-358, async:394-398). With no receipt handle, it returnsfalsewithout logging (:337-340). - The stream
Requeuereturnstruehaving done nothing when the handle is missing (GcpPubSubStreamMessageConsumer.cs:219-222). - SQS strips the handle from the in-memory message before its send
(
SqsMessageConsumer.cs:515, called at:283), and only survives because it copied the handle first (:261). When SQS was changed to release rather than delete on a failed send (#4415), it had to use that same pre-copy — a handle-taking private helper (ReleaseSourceMessageAsync), not the publicRequeue/Nack— for exactly this reason: both read the bag, which no longer has the handle by the time the send fails.
A port that strips the handle without copying it, or that returns
Requeue's result, produces the discard or the outstanding message R-16 and R-19 forbid. - The pull
-
The acknowledgements need the handle too. Without it, the stream
Acknowledgereturns silently (GcpPubSubStreamMessageConsumer.cs:34-37), leaving the message outstanding, and the pullRejectreturnsfalsebefore acking (GcpPullMessageConsumer.cs:278-281). -
Only the pull acknowledgement can fail. The pull
Rejectacknowledges withclient.Acknowledge(GcpPullMessageConsumer.cs:288), which can throw. The streamAcknowledgecompletes a localAccepted()(GcpPubSubStreamMessageConsumer.cs:32-40). -
An exception out of
Rejectstops the performer. The pump callsRejectfrom inside itscatch (DeferMessageAction)andcatch (RejectMessageAction)blocks (Reactor.cs:316,:333;Proactor.cs:348,:367). The loop that contains them has only afinally(Reactor.cs:373,Proactor.cs:408). Today the pullRejectlogs and rethrows an acknowledgement failure (GcpPullMessageConsumer.cs:290-293). -
Every rejection route loops when its publish fails. This covers the budget's route (
Reactor.cs:494-507) and those that bypass it:Unacceptable:Reactor.cs:173-174,:286-287,:343-344,:353;Proactor.cs:325,:379,:388.DeliveryErrorfor a handler'sRejectMessageActionor aConfigurationException:Reactor.cs:279,:293,:309,:337;Proactor.cs:317,:331,:342,:371.
At the defaults nothing slows the loop.
requeueDelay: 0configures noRetryPolicy(GcpPubSubMessageGateway.cs:369-377; the back-off's ceiling isGcpPubSubSubscription.cs:135), andunacceptableMessageLimit: 0is off. -
Native forwarding needs two bindings. Pub/Sub's service agent must hold
roles/pubsub.publisheron the policy's topic androles/pubsub.subscriberon the source subscription. These are the bindings the two IAM helpers create (GcpPubSubMessageGateway.cs:503-521,:555-557).
The ADR MUST record, for each GCP consumer, how Reject is composed so that no failure R-16,
R-17 or R-19 names (a failed routing publish, a failed release, a failed acknowledgement), and no
missing receipt handle, discards a message that has a destination or escapes Reject. Nor may any
of them leave the message outstanding, except in the two accepted cases: R-19's failed release, and
a failed acknowledgement under R-16 or R-17.
R-20. Creating a DLQ-backed GCP channel succeeds when the calling principal cannot read or write
project-level IAM, and when no Cloud Resource Manager surface is reachable at all.
Unimplemented, PermissionDenied and Unauthenticated from the IAM-related calls made during
subscription creation are tolerated: each is logged at Warning, and channel creation continues.
A tolerated-call Warning names five things: the helper it abandoned, the RPC, the resource, the
status code, and the consequence. The consequence is the fixed text native dead-lettering may be inactive, which a test can match. It means that native dead-lettering on this subscription (R-8's
and R-9's route, and R-19's bound) may be inactive until the binding that helper would have made
exists or is provisioned some other way (R-19). That list is fixed here, and every other statement
of it in this document (the bullets below, NFR-5, AC-20, AC-21) refers to it rather than restating
it. The helper name is not decorative:
it is what makes AC-20's "exactly two Warnings, one per helper" checkable at all.
The unit of tolerance is the helper, not the RPC, because the calls are not independent:
GetProjectAsync exists only to derive the member that the GetIamPolicy/SetIamPolicy calls then
consume (GcpPubSubMessageGateway.cs:482-491, :534-543), so continuing past a tolerated
GetProjectAsync with no member derived would carry a null into the binding (:506-511).
Therefore:
- a tolerated status from
GetProjectAsyncabandons that whole IAM helper — no binding is attempted, and one Warning is logged carrying the five elements above; - a tolerated status from
GetIamPolicyAsyncabandons the remainder of that helper, soSetIamPolicyAsyncis not called, with one Warning; a tolerated status fromSetIamPolicyAsyncends that helper with one Warning; - in every case the other helper still runs on its own terms, and subscription creation itself is
unaffected —
EnsureSubscriptionExistsAsyncawaits each helper for effect only and never reads a result (:235,:251).
Both helpers run whenever DeadLetter != null, so a condition that fails for one fails for both:
the tolerated-call count, and therefore the Warning count, is two in the ordinary case, one from
each helper. A failure to construct the Resource Manager client —
credential resolution failing before any RPC is issued, so there is no status code to inspect — is
tolerated on the same terms and logged the same way.
Unauthenticated is in the tolerated set because it is the status the emulator actually produces.
PUBSUB_EMULATOR_HOST cannot redirect Cloud Resource Manager, which is a different service with its
own client, so GetProjectAsync leaves for real GCP and is rejected there; spec 0036 recorded this
measurement (conformance-status.md, §GCP). A tolerance list without it leaves channel creation
hard-failing one call before the GetIamPolicy case, and R-21's local bar unreachable.
No other status code is swallowed — NotFound, InvalidArgument, DeadlineExceeded,
ResourceExhausted and every other status still propagate, so a genuine misconfiguration is not
masked. The tolerance is scoped to the IAM-related calls tabulated below; it does not extend to
topic creation, subscription creation, or any call on the message path.
R-20 covers both IAM helpers, not one. The calls to tolerate are those made from
GcpPubSubMessageGateway.EnsureSubscriptionExistsAsync when DeadLetter != null:
| call site | method | RPCs |
|---|---|---|
:235 |
UpdateIAmRoleForDeadLetterAsync (:477) |
ProjectsClient.GetProjectAsync; IAMPolicyClient.GetIamPolicyAsync / SetIamPolicyAsync on the dead-letter topic |
:251 |
UpdateIAmRoleForSubscriptionAsync (:527) |
ProjectsClient.GetProjectAsync; IAMPolicyClient.GetIamPolicyAsync / SetIamPolicyAsync on the subscription |
DeadLetter != null
condition and makes the same two kinds of call, so tolerating only the first would still hard-fail
on the emulator one line later. Both are in scope.
Example (emulator, members not configured). Against the Pub/Sub emulator (
gcr.io/google.com/cloudsdktool/cloud-sdk:emulators,PUBSUB_EMULATOR_HOST=localhost:8085),GetProjectAsyncreturnsUnauthenticatedin both helpers. Each abandons its binding at that point, soGetIamPolicyAsyncis never reached; two Warnings are logged, one per helper, and channel creation returns a usable channel. Example (emulator, members configured). WithDeadLetter.PublisherMemberandSubscriberMemberset (C-11), no Resource Manager call is issued at all; onlyGetIamPolicyAsync'sUnimplementedis tolerated — once in each helper, so two Warnings are logged. Example (real project, restricted principal). Against a real project with a service account holdingpubsub.subscriberandpubsub.publisherbut notresourcemanager.projects.get,GetProjectAsyncreturnsPermissionDeniedin both helpers, each abandons its binding, two Warnings are logged, and channel creation succeeds.
R-21. The GCP work in this spec is verified by a local, repeatable run against the Pub/Sub
emulator.
gcp-ci against a real project is welcome as corroboration but is not the bar. A GCP requirement
whose only evidence is a cloud-only run is not "done" under this spec. This is why R-20 is scoped in
rather than deferred to #4354: it is the only route to a local proof. One exception is accepted
in advance: on a configuration where A-6 is refuted, R-19's bounding clause has no local evidence
and rests on the service's documented behaviour, corroborated on gcp-ci where that runs but not
required to be. R-19 is then "done" on the rest of its evidence (AC-18, and AC-43's remaining
assertions beside its refutation record), and AC-31 states the gap.
Example.
docker-compose -f docker-compose-gcp.yaml up -d, then the scoped GCP conformance suite, thendocker-compose -f docker-compose-gcp.yaml down -vand a repeat run, produce the same result twice from a clean store.
R-22. The nine configurations that already satisfy conformance behaviour FR-23 continue to do so,
unchanged.
Those are Redis / RedisMessagingGateway, Kafka / Classic, Kafka / Consumer,
Kafka / PartitionKey, MSSQL / MSSQLMessagingGateway, PostgresSQL / PostgresMessagingGateway,
RMQ.Async / Classic, RMQ.Async / Quorum, RMQ.Sync / RmqSyncMessagingGateway. All nine
requeue by republishing, so the header travels; the three RMQ rows differ only in that a rejected
message reaches its destination by broker-routed rejection (the DLX) rather than a Brighter-managed
send. They already satisfy R-1 to R-5,
and nothing in this spec alters their consumers, their subscriptions, their producers, or the
pump code they share.
Example. Before and after this spec,
Kafka / ClassicwithrequeueCount: 3and a deferring handler produces three deliveries presenting counts0,1,2, aDeliveryErrorrejection, and a dead-lettered message withHandledCount >= 2. The same holds for the other eight.
R-23. No conformance cell currently recorded as Pass or Fixed regresses, in any behaviour
column, for any configuration.
Broader than R-22: the delivery-count contract touches the receive path of the transports in scope,
and that path serves all twelve conformance behaviours, not only FR-23. R-2 exists precisely because
this could otherwise break the identity assertions of FR-2, FR-15, FR-16 and FR-22 on eight AWS
cells.
R-2 protects only the first-delivery arm of those assertions. The same four behaviours also
assert identity on the redelivered message, where R-1 requires a count of at least 1; that arm's
HandledCount comparison is relaxed to >= by ADR 0077-delivery-count-contract (a conformance
oracle change under ADR 0067-conformance-rollout-and-deferral-governance), which is what keeps
those cells Pass. (Amended at design, 2026-09-24.)
Example.
AWS / SqsStandardcurrently readsPasson FR-2, FR-4, FR-5, FR-6, FR-7, FR-8, FR-9, FR-15, FR-16, FR-17 and FR-22. After this spec all eleven still readPass, and FR-23 moves fromDeferredtoFixed.
R-24. No public API currently shipped in V10 is removed, renamed, or has its meaning changed in a
way that breaks a compiling V10 consumer.
Additions are permitted: new interface implementations on existing subscription types, new optional
constructor parameters appended with defaults, new log messages. Subscription.RequeueCount's
default stays -1. MessageHeader.HandledCount's type and name are unchanged.
The evidence for R-24 is a committed artefact, not an inspection. Compile-only samples are
committed, written against the V10 public surface as it stands before this spec, constructing the
affected types with the argument shapes a V10 application uses today. They are compiled by the
normal build, so a breaking change fails the build rather than awaiting review, and they are the
artefacts AC-27 names. #pragma warning disable is not permitted in them: a new obsoletion warning
must surface.
Each sample lives in a project that already references the assembly it exercises. No test project gains a reference it does not have today, and no new project is created:
| Sample covers | Committed in | Already references |
|---|---|---|
Subscription, MessageHeader, Message |
tests/Paramore.Brighter.Core.Tests/ |
Paramore.Brighter |
SqsSubscription |
tests/Paramore.Brighter.AWS.Tests/ |
…MessagingGateway.AWSSQS |
SqsSubscription (v4) |
tests/Paramore.Brighter.AWS.V4.Tests/ |
…MessagingGateway.AWSSQS.V4 |
GcpPubSubSubscription |
tests/Paramore.Brighter.Gcp.Tests/ |
…MessagingGateway.GcpPubSub |
RocketMqSubscription |
tests/Paramore.Brighter.RocketMQ.Tests/ |
…MessagingGateway.RocketMQ |
The samples are compile-only: they construct and assign, and assert nothing, so they neither need
broker infrastructure nor run as tests. Paramore.Brighter.Core.Tests references no messaging
gateway and must not acquire one for this purpose.
Example. An application that today constructs
new SqsSubscription<MyCommand>(subscriptionName: ..., channelName: ..., routingKey: ..., requeueCount: 3)compiles unchanged and continues to behave as before, except that its budget now runs down — which is the fix, not a break.
R-25. The three budget-configuration findings are reported by startup pipeline validation, as
ISpecification<Subscription> rules, at ValidationSeverity.Warning.
Brighter already has this seam and it is the right home: ValidatePipelines() resolves subscriptions
and collects ISpecification<Subscription> rules
(BrighterPipelineValidationExtensions.cs:78-84), and ConsumerValidationRules
(Paramore.Brighter.ServiceActivator/Validation/ConsumerValidationRules.cs) is where consumer
subscription rules already live. ADR 0053-pipeline-validation-at-startup established it precisely for "configuration mistakes …
only discovered at runtime". Warning-severity findings never block startup regardless of
throwOnError, which is the correct severity for all three: each names a legal configuration that
is probably not what its author meant. ConsumerValidationRules.RequestTypeSubtype() is the
existing precedent for a Warning-severity subscription rule.
| Rule | Fires when | The finding must name |
|---|---|---|
| Zero or negative budget (R-7) | RequeueCount == 0 or RequeueCount < -1 |
the subscription, the configured value, and the two likely intents — -1 (unlimited) or 1 (once only) |
| Budget outranked by native redrive (R-10) | R != -1, a native redrive limit M is configured, and R >= M |
the subscription, R, M, and that the effective limit is M |
| Budget structurally unenforceable (R-11) | the transport cannot satisfy R-1 for this subscription | the subscription, R, and the reason the count cannot advance |
Example. An application calling
.ValidatePipelines()with a subscription configuredrequeueCount: 0starts successfully and its validation report contains one Warning naming that subscription,0,-1and1. WithrequeueCount: 3it contains no such Warning.
RequeueCount is on Subscription in core (src/Paramore.Brighter/Subscription.cs:109), so
R-7's rule is transport-agnostic and lands in ConsumerValidationRules as-is. M is not: it is
SqsAttributes.RedrivePolicy.MaxReceiveCount (SqsAttributes.cs:110) and
GcpPubSubSubscription.DeadLetter.MaxDeliveryAttempts (DeadLetterPolicy.cs:47), owned by
transport assemblies that neither core nor ServiceActivator may reference.
This exact question is already decided, for a different set of inputs, by
0074-lifetime-validation-evaluation-site on the unmerged branch spec/scoped-lifetime-per-pipeline
(PR #4282, Accepted, implementation
complete). It establishes a two-rung ladder, and states the test for choosing a rung:
"An assembly that owns a concept contributes the rules for that concept…
ServiceActivatoralready supplies fourISpecification<Subscription>rules through the container today. That seam works becauseSubscriptionis a core type. This ADR's entity types cannot be core types, so the pull moves up one level — from specifications to validators."
| Rung | Use when | Mechanism |
|---|---|---|
| Specification | the entity the rule reads is a core type | the owning assembly registers an ISpecification<Subscription>; ValidatePipelines() already collects them |
| Validator | the entity cannot be a core type | the owning assembly registers its own IAmAPipelineValidator beside the core one; both hosts resolve IEnumerable<IAmAPipelineValidator> and Combine(...) |
ADR 0074-lifetime-validation-evaluation-site took the upper rung only because its inputs were container concepts — service
descriptors, which can never be core types. Ours are not: M is an integer on a subscription,
and a core role interface exposing it (the idiom IUseBrighterDeadLetterSupport already
establishes, and which lives in core at src/Paramore.Brighter/IUseBrighterDeadLetterSupport.cs)
makes the entity a core type again and keeps the rule on the lower rung.
R-11's input has a different shape from R-10's, and the ADR must place it explicitly. R-10 reads
an integer, M. R-11 reads a per-subscription capability — "can the delivery count advance for
this subscription?" — whose answer depends on the transport, on transport-specific configuration
state (by A-1's worked example, a GcpPubSubSubscription with no DeadLetterPolicy), and on the
mechanism the ADR itself selects. The argument that returns R-10 to the lower rung does not carry
over on its own: an integer is trivially exposed by a core role interface, a capability judgement is
not until someone defines the predicate.
The ADR MUST therefore do one of two things for R-11, and record which:
- Lower rung — define the core-visible predicate the transport subscription answers (the shape
IUseBrighterDeadLetterSupportalready establishes: a core role interface the transport subscription implements, here exposing whether this subscription can enforce a delivery budget and why not when it cannot), so R-11's rule stays anISpecification<Subscription>alongside R-7's and R-10's. - Upper rung — register the rule as an
IAmAPipelineValidatorfrom the transport assembly, and record the #4282 sequencing dependency that follows.
The ADR MUST also name, for each of the four transports in scope, at least one subscription shape that trips R-11 under its chosen mechanism — or state that none does. AC-11 reads that list, and its second branch defines what "done" means for R-11 when the list is empty.
The requirements therefore constrain the ADR as follows, without choosing for it: the ADR MUST adopt one of the two rungs above for each of the three rules and MUST record which and why. Introducing a third mechanism for contributing a validation rule from another assembly is out of bounds — that is the specific outcome this note exists to prevent. The lower rung is expected to suffice; if the ADR takes the upper one it must say what the role interface could not carry.
R-26. R-11's finding is additionally logged once at channel creation; R-7's and R-10's are not.
ValidatePipelines() is opt-in by design (ADR 0053-pipeline-validation-at-startup, NFR-1), so a finding reported only there is
invisible to an application that never opts in. That is acceptable for R-7 and R-10, which describe
a configuration the author chose and can review. It is not acceptable for R-11, which describes
Brighter being unable to honour a setting it accepted — the exact silence this defect family exists
to close, and not the user's mistake. R-11 is therefore reported by both routes: the R-25 rule, and
a single Warning at channel creation, once per channel.
R-27. The conformance harness is configured so that the Brighter budget fires ahead of any native redrive limit on every configuration this spec moves, and so that the GCP providers reach channel creation without a Cloud Resource Manager call, and so that the FR-23 behaviour can observe how many times a message was delivered. Three changes, all to test-infrastructure code, all required for the behaviours above to be exercised deterministically rather than raced or taken on trust.
(a) R < M on all twelve providers — four AWS, four AWS.V4, four GCP, each verified to hold the values in the table below as of 2026-09-21. Today R == M on both families (A-5), which is harmless only
while the budget is inert and becomes R-8's undetermined tie case the moment it works. The budget
must be strictly below the native limit so the Brighter route is the one the FR-23 behaviour
exercises:
| Provider files | Today | Required |
|---|---|---|
tests/Paramore.Brighter.AWS.Tests/MessagingGateway/ — SnsStandardMessageGatewayProvider.cs, SnsFifoMessageGatewayProvider.cs, SqsStandardMessageGatewayProvider.cs, SqsFifoMessageGatewayProvider.cs, and the four same-named files under tests/Paramore.Brighter.AWS.V4.Tests/MessagingGateway/ |
requeueCount: 3, maxReceiveCount: 3 |
R = 3, M = 5 |
tests/Paramore.Brighter.Gcp.Tests/MessagingGateway/ — GcpPullMessageGatewayProvider.cs, GcpPullOrderingMessageGatewayProvider.cs, GcpStreamMessageGatewayProvider.cs, GcpStreamOrderingMessageGatewayProvider.cs |
requeueCount: 5, MaxDeliveryAttempts: 5 |
R = 3, M = 5 |
M = 5 is the lowest value Pub/Sub accepts (C-6), so R <= 4 is forced on GCP; R = 3 is chosen on
both families so the two read alike and so NFR-7's window is comfortably met.
(b) The four GCP providers set both IAM members explicitly — each to a non-empty
serviceAccount:…-shaped string — per C-11, so the Resource Manager call is not issued on the
emulator at all. Non-empty matters: the guard is string.IsNullOrEmpty, so "" trips it and
fails to derive a replacement (the ??= fallback does not fire for an empty string), which is the
one way to set the members and still reach the path R-27(b) exists to avoid. This is belt and braces with R-20, not a substitute for
it: R-20 governs what happens to an application that does not set them, which is the default
a user meets.
Amended 2026-10-03 (PR #4506). The members are set only when
PUBSUB_EMULATOR_HOSTis set. Real Pub/Sub validates IAM members, so the placeholder account failed channel creation withInvalidArgument: Service account … does not existingcp-ci, which runs against real GCP. There, the providers leave both members unset and the gateway derives the project's own Pub/Sub service agent. Tests that can only run on the emulator carry[Trait("Requires", "PubSubEmulator")], andgcp-ciexcludes them.
(c) The shared conformance harness counts deliveries, and both FR-23 templates assert the count.
R-4's obligation — that a message is not delivered again once its budget is spent — is only
observable if the harness can say how many times the message was dispatched to a handler. It cannot
today: ConformanceDeferredPump constructs a fresh handler instance per dispatch
(ConformanceDeferredPump.cs.liquid:128, :152) and the handler body only throws
DeferMessageAction (:45-49, async :52-57), so no count accumulates anywhere. R-27(c) therefore
requires:
- A dispatch count exposed by the shared pump, keyed on the identity of the originating
message, reached from a handler through
Context.OriginatingMessage, which both pumps set before dispatch (Reactor.cs:416,Proactor.cs:483) andPipelineBuildersupplies to the handler (:280,:323). The key is the originating message'sx-original-message-idwhen it carries one, and itsHeader.MessageIdotherwise — the identity ruleConformanceDeferredPump.AssertIsTheMessageSentalready applies, viaMessage.OriginalMessageIdHeaderName.Header.MessageIdalone is not sufficient: a transport that requeues by republishing mints a fresh id per redelivery and records the first inx-original-message-id(RmqMessagePublisher.cs:131,:142), so a count keyed onMessageIdalone would record1against each of three distinct keys andcount <= RequeueCountwould pass vacuously. That is the state of the three RMQ configurations, which R-27(c)(4) regenerates, so the fallback is load-bearing today and not a hypothetical. A transport that republishes under a fresh id is still one message for counting purposes. The key is also not the command's own id, whichCommandBody()mints at serialise time (ConformanceDeferredPump.cs.liquid:112-115) and which is a different value from the one the message carries. - Reset between tests, so a count never leaks from one test to the next through a pump shared within a project.
- Read after the pump has been quit and awaited — the instant R-4 defines as the close of the observation window. That is strictly later than the dead-letter poll's break, so a dispatch that happened between the message reaching the destination and the pump stopping is still counted. A test may also read the dispatch count live as a stop trigger — AC-1 quits the pump when it reaches 3 — but the value it asserts is the one read after quit and await. A test may likewise take a live snapshot as a comparison baseline: AC-43 compares the count on arrival with the count after quit and await. What it asserts is still the value read after quit and await.
- An assertion in both FR-23 templates (
…/Reactor/When_requeuing_a_message_too_many_times_should_move_to_dead_letter_queue.cs.liquidand its Proactor twin) that the count is<= RequeueCount, and regeneration of the generated FR-23 tests for every configuration. - Availability to the ACs outside FR-23 that also assert a count — AC-1, AC-5, AC-6, AC-34's
second clause, AC-35, AC-42 and AC-43 — which run against bespoke subscriptions rather than a conformance
provider (R-27(a) fixes all twelve providers at
R = 3, so none supplies-1,4,1,0,-3, a short visibility timeout / ack deadline / invisible duration, or a failing routing destination, which is what makes AC-43 bespoke). The pump-driven ACs among them (all but AC-42) use the same counter and the same key; obligation 6 specifies the requeue observation that AC-6 and AC-35 also make. AC-42 runs no pump, so it has no dispatch count: it uses only obligation 6's per-receive record, or reads the count directly from the returned message. - A requeue observation, specified to the same degree. AC-6's and AC-35's "
Requeueis never called" is counted, not inferred: a recording consumer the test composes around the real consumer — a decorator implementing the same consumer interface, forwarding every call and countingRequeue/RequeueAsyncby the key of obligation 1 — reset and read as obligations 2 and 3 require. "Never called" is thencount == 0for that message id. It is not a mock standing in for a transport (C-10 forbids that), not a counter added to production code, and not an inference from the absence of a redelivery inside some unstated interval. The same recording consumer also records the integer value ofHeader.HandledCountat the moment itsReceive/ReceiveAsyncreturns each message, before the pump sees it — a value copied, not a reference to the message, because the pump then increments that same header (MessageHeader.cs:572-575) — in delivery order and by the key of obligation 1. That record is the per-delivery count AC-1, AC-34's second clause and AC-42 assert on; it is read as obligations 2 and 3 require (for a pump-less AC such as AC-42, after its last receive).
"Dispatch count", "delivery count presented to the pump" and "handler invocation count" are used
interchangeably for this counter: it advances once per dispatch of a message to a handler. It is
test-harness state and has no relationship to MessageHeader.HandledCount, which is the
delivery count of Group A.
Without (c) the invocation-count clauses of R-4, AC-3, AC-5, AC-6, AC-35 and AC-43, and the per-delivery counts of AC-1, AC-34 and AC-42, are unassertable, and the FR-23 behaviour can only show that a message reached the DLQ, not that it stopped being delivered.
Example. After R-27(a),
AWS / SqsStandardwith a deferring handler exhausts its budget of 3 on the third delivery (or earlier on an approximate counter, R-4) and reaches the Brighter-managed DLQ; SQS's own counter reaches 3 of 5 and never redrives. Before R-27(a) the same run is R-8's tie and either route may fire. Example. After R-27(c), the same run asserts that the dispatch count for that message id stands at 3 or fewer — 3 on an exact counter, possibly fewer on SQS's approximate one (R-4) — when the pump has been quit and awaited (R-27(c)(3)), not when the DLQ poll breaks. Before R-27(c) the run cannot tell 3 deliveries from 30.
NFR-1. No additional broker round trip per delivery. Whatever supplies the delivery count must
come from data already present on the receive response or already carried by the message. A
mechanism that issues an extra GetQueueAttributes, GetSubscription, or equivalent call per
received message is excluded.
Baseline to hold.
SqsMessageConsumeralready requestsMessageSystemAttributeNames = ["All"]on every receive (:188-194, V4:276), so SQS's system attributes are on the wire at no extra cost.grep -rn "ApproximateReceiveCount" src/returns nothing — nothing reads them today.
NFR-2. No new per-message heap allocation on the receive path. Reading and normalising a delivery count must not introduce a new collection, dictionary, string concatenation or boxed value per received message beyond what the path already performs. Verified by measurement, not by inspection: AC-37 fixes the method (an allocated-bytes delta over a fixed number of receives, compared against the pre-change baseline) and the budget (no increase).
NFR-3. No additional broker round trip per requeue. A mechanism that satisfies R-1 by adding a broker call on every requeue is permitted only if it replaces an existing call, not if it adds one.
NFR-4. A budget that will not behave as its author probably intended is visible at Warning, at
most once per subscription per rule, and never per message.
R-7 (a budget that rejects on the first delivery), R-10 (a budget outranked by a native redrive
limit) and R-11 (a budget that cannot run down at all) are the three instances, surfaced through
startup pipeline validation (R-25) and, for R-11 only, additionally at channel creation (R-26).
Budget exhaustion itself continues to log through the existing Log.DroppingMessage path.
"Per rule", not "per subscription", because R-25's three rules are independent and their conditions
are not mutually exclusive. A subscription with requeueCount: 0 on a transport that cannot
satisfy R-1 trips R-7's rule and R-11's, and R-26 then adds R-11's channel-creation Warning on top.
Two validation findings plus one log line, for one subscription, is correct behaviour rather than a
breach of this NFR. What this NFR forbids is repetition per message: nothing on the receive path
raises a budget Warning, so a high-throughput consumer cannot be flooded by one. AC-29 counts the
channel-creation route; AC-7, AC-10 and AC-11 each count their own rule's finding.
NFR-5. Tolerated IAM failures are visible and narrow. Each tolerated Unimplemented,
PermissionDenied or Unauthenticated logs one Warning carrying the five elements R-20 fixes. No catch (Exception) may be used to implement R-20 — the tolerance is by status code,
so an unrelated failure is never hidden. R-20's client-construction case, which carries no status
code, is scoped to the Resource Manager client construction alone and logs a Warning carrying the
same five elements, with the RPC element reading as the operation abandoned rather than a call made.
The ADR MUST name the exception type caught there: "the narrowest type that call site can throw"
is not a specification, and two implementers would choose two different types. A
tolerated failure never widens: the subscription is still created, and no other call is affected.
NFR-6. The AWS v3 and v4 packages remain behaviourally indistinguishable. Any divergence
introduced by this spec between …MessagingGateway.AWSSQS and …MessagingGateway.AWSSQS.V4 is a
defect, per ADR 0038-aws-sqs-dlq-direct-send's dual-package constraint.
NFR-7. The FR-23 behaviour completes inside the conformance template's observation window for
every configuration this spec moves. The template polls the dead-letter destination every 500 ms
and gives up after 60 s. Any budget/lease combination this spec relies on must fit — e.g. RocketMQ's
10 s invisibility lease and a budget of 3 occupy ~30 s and fit; a budget of 10 on the same lease
would not. A configuration that cannot fit the window is a harness defect to be fixed under
R-27, not evidence about the transport and not grounds for leaving a cell Deferred.
Evidence is unconditional for the eight AWS cells (AC-12) and conditional for the rest, because two of the three ACs are branch-guarded: AC-19 (the four GCP cells) applies only on the branch the ADR's recorded conclusion selects (see the note under AC-40; AC-39 is a measurement, not a selector), and AC-24 (RocketMQ) only when R-14's condition holds. On the AC-40 or AC-25 branch the corresponding configurations are not moved by this spec at all, so NFR-7 has nothing to evidence for them — it constrains only the configurations a branch actually moves.
NFR-8. Both variants, always. Every behavioural requirement in Groups A–D is demonstrated in
both variants (see Terms, Variant): Reactor and Proactor where a pump runs, and the paired
synchronous and asynchronous consumer calls — sync with sync, async with async — where an AC drives a
consumer directly. Spec 0036's FR-14 rule: one variant is not evidence.
- C-1. ADR
0038-aws-sqs-dlq-direct-send's DLQ strategy stands.Rejectsends directly to the Brighter-managed DLQ and deletes the original; native redrive is not leaned on for rejected messages. ADR0038-aws-sqs-dlq-direct-sendexplicitly considered and rejected the visibility-trick alternative. This spec makes the budget-exhaustion route into that path reachable; it does not change the path. - C-2. This is one spec, not three. #4341 is the family head. #4386 and #4353 become implementations of the contract settled here, plus #4386's own rejection-routing decision.
- C-3. #4354 is in scope (R-20), because it is the only route to C-4.
- C-4. The GCP verification bar is a local emulator run (R-21), not a
gcp-ci-only run. - C-5. RocketMQ is conditional by design (R-14). The branch is real and both sides have a defined "done".
Listed here so the ADR starts from a complete set, and so no reader mistakes a requirement above for an instruction to implement one of these.
| Mechanism | What is already true | What the ADR must weigh |
|---|---|---|
| Read the broker's own delivery counter on receive | SQS ApproximateReceiveCount is already on the wire (NFR-1). Pub/Sub exposes ReceivedMessage.DeliveryAttempt and PubsubExtensions.GetDeliveryAttempt(PubsubMessage) (Google.Cloud.PubSub.V1 3.36.0). RocketMQ exposes MessageView.DeliveryAttempt as a public property, set from systemProperties.DeliveryAttempt on receive (RocketMQ.Client 5.2.1). |
All three counters are documented approximate/best-effort (R-3). The seams are SqsMessageCreator.ReadHandledCount (:323), SqsInlineMessageCreator.ReadHandledCount (:350), GcpPubSub/Parser.ToBrighterMessage (:84) and ReadHandleCount (~:169), RocketMessageConsumer.ReadHandledCount (:422). Note the origin offset: all three counters read 1 on first delivery, while R-2 requires 0. And note R-28: the same counter is present on the rejection destination's own queue, so substituting it for the stamped header there would report that queue's count and destroy R-5's evidence — a mechanism on this row must discriminate a routed message from a source-channel delivery, and name how. |
| Rewrite the stored message on requeue (republish) — ⛔ excluded by NFR-3, see C-12 | This is what the nine conforming transports do. | Nothing left to weigh: C-12 records the exclusion and its two reasons. Listed so the ADR does not re-open it, and so a future spec that revisits NFR-3 finds the argument rather than re-deriving it. |
| Track the count consumer-side | No broker dependency at all. | Does not survive a process restart or competing consumers; an unbounded in-memory map is an allocation and leak risk (NFR-2). |
- A-1. Pub/Sub populates
delivery_attemptonly when the subscription carries aDeadLetterPolicy. Verified against Google.Cloud.PubSub.V1 3.36.0's own XML documentation: "Upon the first delivery of a given message,delivery_attemptwill have a value of 1… If a DeadLetterPolicy is not set on the subscription, this will be 0."GetDeliveryAttemptreturnsnullin the same case. Consequence: if the ADR chooses the broker-counter mechanism for GCP, R-11's Warning is load-bearing for subscriptions without aDeadLetterPolicy. - A-2. The Pub/Sub emulator populates
delivery_attempton a DLQ-backed subscription, and advances it on each redelivery. Both halves are required: a counter the emulator populates but never advances (1, 1, 1) fails R-1 exactly as an absent one does, and AC-39 measures the sequence, not merely its presence. Not verified. It cannot be verified until R-20 makes DLQ-backed channel creation possible on the emulator. If it is refuted, the ADR must find a GCP mechanism that does not readdelivery_attempt; if none satisfies R-1 to R-5, R-13's second branch applies and GCP becomes bound but unimplemented with the fourGCP / *FR-23 cells re-pointed and the measurement recorded. A-2 holding does not by itself select R-13's first branch: the ADR's recorded conclusion does (R-13). Falling back to agcp-ci-only proof is not one of the available outcomes (R-21). The twenty GCP rejection-routing cells (R-15 to R-19) do not depend on A-2. - A-3. The RocketMQ broker increments
DeliveryAttempton a lease-lapse redelivery, with noChangeInvisibleDurationcall. Not verified. This is exactly R-14's condition, and AC-23 measures it. - A-4. SQS's
ApproximateReceiveCountand Pub/Sub'sdelivery_attemptare approximate by their vendors' own documentation. Hence R-3's two-tier obligation and R-4's "at mostR" upper bound rather than an equality. - A-5. The existing harness configurations put the Brighter budget and the native redrive limit at
the same value — AWS
requeueCount: 3/maxReceiveCount: 3(SqsStandardMessageGatewayProvider.cs:104,108and the three sibling providers), GCPrequeueCount: 5/MaxDeliveryAttempts: 5(GcpPullMessageGatewayProvider.cs:151,155and its three siblings). Once the contract works these race (R-8's tie case), so the harness must be re-configured toR < Mfor the FR-23 behaviour to exercise the Brighter route deterministically. Owned by R-27, which names the twelve provider files and the required values. - A-6. The Pub/Sub emulator enforces
DeadLetterPolicy.MaxDeliveryAttempts— it forwards a message to the policy's dead-letter topic once its delivery attempts reach the limit, counting Nacks andModifyAckDeadline(…, 0)releases as attempts, as the production service documents. Not verified, and not measurable until R-20 makes a DLQ-backed channel creatable on the emulator. It is what bounds R-19's loop there, and AC-43 measures it. If it is refuted, R-19's release is unbounded on the emulator: AC-43 records the refutation, per configuration, inconformance-status.md's GCP paragraph in place of its arrival clause, and R-19's production statement, which rests on the service's documented behaviour, is unchanged. That is the one accepted exception to R-21's local bar, and AC-31 states it.
-
C-6. Pub/Sub requires
DeadLetterPolicy.MaxDeliveryAttemptsto be between 5 and 100 (DeadLetterPolicy.cs:47). So on a DLQ-backed GCP subscription a Brighter budget must be<= 4to be reachable ahead of native redrive under R-8. -
C-7.
DefaultMessageAssertionassertsHandledCountequality (tools/Paramore.Brighter.Test.Generator/Templates/DefaultMessageAssertion.cs.liquid:59), which is what makes R-2 non-negotiable. It applies to redelivered messages too, where R-1 makes equality unattainable; ADR0077-delivery-count-contractrelaxes that arm to>=(see R-23). (Amended at design, 2026-09-24.) -
C-8.
RocketMqSubscriptionalready implements both support interfaces, so R-14's "bound but unimplemented" outcome requires no new binding work — only the Warning and the ledger entry. -
C-9. RocketMQ local infrastructure is fragile and
rocketmq-cihas been commented out since #3696, so local is the only place RocketMQ ever runs. The dead-letter topics are shared and persistent; onlydocker-compose … down -vfollowed byup -dgives a clean store. Budget for this in R-14's measurement. -
C-10. House conventions apply (
.agent_instructions/testing.md,.agent_instructions/design_principles.md): xUnit,When_[condition]_should_[expected_behavior]method and file naming,[Behavior]Testsclass naming, one test case per file, Arrange/Act/Assert comments, no mocks used for isolation, Docker Compose for broker infrastructure, structural changes separated from behavioural ones, and the TDD review gear honoured. -
C-11. Explicitly configured IAM members skip the Cloud Resource Manager call. Both helpers derive the default Pub/Sub service account only when the member is unset:
UpdateIAmRoleForDeadLetterAsyncreadsDeadLetter.PublisherMember(DeadLetterPolicy.cs:27) andUpdateIAmRoleForSubscriptionAsyncreadsGcpPubSubSubscription.SubscriberMember(GcpPubSubSubscription.cs:102); each callsGetProjectAsynconly inside astring.IsNullOrEmpty(...)guard (GcpPubSubMessageGateway.cs:482-491,:534-543). Setting both therefore removes the Resource Manager dependency entirely. This is existing behaviour, not work: R-27(b) uses it in the harness, and R-20 covers the default case in which a user has set neither. Verified in source 2026-09-21. -
C-12. NFR-3 stands, and it excludes the republish mechanism. Decided 2026-09-22. No transport in scope has an in-place update API — not SQS, not Pub/Sub, not RocketMQ — so "rewrite the stored message on requeue" necessarily means delete-and-republish. That adds a net broker call per requeue on all three (SQS
ChangeMessageVisibility→SendMessage+DeleteMessage; Pub/SubModifyAckDeadline→Publish+Ack; RocketMQ's requeue issues no broker call at all today), which is what NFR-3 forbids. Two reasons to keep NFR-3 rather than reword it:- Bypassing the broker's own redelivery is counter-intuitive for users. A user who configured a visibility timeout or an ack deadline expects Brighter to use it. Republishing changes message ids, queue position and the broker's own receive metrics, none of which a requeue is expected to disturb.
- SQS FIFO would break silently. Content-based deduplication hashes the body, and a requeue
changes only a header attribute, so a republished copy is discarded as a duplicate inside the
dedup window unless Brighter mints a fresh
MessageDeduplicationIdper requeue — which it does not do today (SqsMessageSender.cs:100-102sets one only when the bag carries it). R-12 puts all four FIFO configurations in scope, so this is not an edge case.
The cost of this decision, recorded so it is not rediscovered as a surprise: the republish mechanism would have made the contract exact on every transport and dissolved both of this spec's conditionals — RocketMQ would satisfy R-1 to R-5 without waiting on the upstream
ChangeInvisibleDurationfix, and GCP would not depend on the emulator populatingdelivery_attempt. Keeping NFR-3 keeps R-14's branch and A-2's risk live, and accepts that GCP FR-23 and RocketMQ FR-23 may both end at bound but unimplemented. R-13 and R-14 already define a "done" on both branches, which is what makes that acceptable.It also makes R-28 load-bearing: with republish excluded, a delivery count that is synthesised on read is the likely mechanism, and R-28 is what stops that synthesis destroying R-5's evidence.
Named explicitly so the boundary is deliberate rather than accidental.
- #4351 — MQTT FR-23. A
Proactorsync-over-async deadlock:MqttMessagePublisher's constructor blocks on its own connect andMqttMessageConsumerbuilds its requeue producer lazily insideRequeueAsync, on the pump's single thread. A different root cause from this family; theMQTT / MqttMessagingGatewayFR-23 cell does not move here. Routed to/bugfix. - #4321 — GCP zero-delay requeue latency. Separate issue.
- #4387 — RMQ invalid-message destination. It should follow whatever R-15 to R-19 settle for GCP rather than invent a second pattern, but it is not implemented here.
- Changing ADR
0038-aws-sqs-dlq-direct-send's DLQ strategy (C-1). - Changing SQS's failed-DLQ-send behaviour to match R-19 — resolved by #4415. This entry
originally recorded an open question raised when this spec's requirements were approved:
SqsMessageConsumer.RejectAsyncdeleted the source message when the DLQ send threw, which its own code comment recorded as deliberate infinite-loop avoidance, against R-19's opposite choice for GCP. That question is now answered: issue #4415 (PR #4523, merged 2026-10-05) changed both SQS packages (V3 and V4, lockstep per ADR 0038) to match R-19 — release for redelivery instead of deleting, on the same accepted loop/retention trade-off (see the R-19 discussion above and the ADR 0038 amendment). Full diagnosis, evidence and scope inbugfixes/0048-sqs-reject-deletes-on-dlq-failure/bugfix.md. Still open, not resolved by #4415: a DLQ/invalid-message producer-creation failure (as opposed to a publish failure) still falls through to delete (bugfix.md, Scope Notes Risk 2) — low practical reachability, left for a future issue. - Azure Service Bus. It dead-letters natively, has no emulator in this repo, and is not part of
this defect family. Its
Deferredcells do not move here. - The RabbitMQ transports. All three already satisfy FR-23 via the DLX; R-22 protects them and nothing else touches them.
- The upstream RocketMQ C# client
ChangeInvisibleDurationfix. Not ours to make, and R-14 exists so this spec does not wait on it. - Changing
Subscription.RequeueCount's default from-1(R-6, R-24). RocketMessageConsumer.ReadDelay's header defect — recorded in §Additional Context, not fixed here.- Any conformance cell not named in AC-30. In particular, this spec does not claim the GCP FR-2, FR-7, FR-9, FR-15, FR-16 or FR-22 cells: R-20 may make them runnable locally for the first time, but a cell moves on evidence, and whatever such a run reveals is new information deserving its own issue.
Each is written so it can become a test assertion as written. Both variants are required throughout per NFR-8; where an AC names one variant in an example, the obligation covers both.
Which transports these ACs bind. AC-1, AC-42, AC-3, AC-4, AC-34's second clause and AC-41 are
unconditional for AWSSQS and AWSSQS.V4 (R-12). For GcpPubSub and RocketMQ they apply only on
the branch R-13 / R-14 selects as implemented; on the bound but unimplemented branch, R-13(a)-(c)
and R-14(a)-(d) define "done" instead, and AC-40 (GCP) and AC-25 (RocketMQ) are the criteria that
apply. "Any / each transport in scope" below is read under this guard. AC-2, AC-5, AC-6 and AC-35
are not guarded. AC-2 because a first delivery presenting 0 is true today and must stay true on
both branches — the bound but unimplemented branch may still touch the receive path (AC-38), and
AC-2 is also R-23's criterion. AC-5, AC-6 and AC-35 because they need no count to advance: at -1
the budget is never consulted (MessagePump.cs:171), and at 0, 1 or below -1
HandledCountReached is true on the first deferral whatever the transport presents.
AC-1 (R-1, R-3) — Given a subscription on any transport in scope with requeueCount: -1 — so
the budget cannot end the run before the third delivery, which at requeueCount: 3 an approximate
counter may (R-4's example) — no native redrive limit at or below 3, on GCP a DeadLetterPolicy
with MaxDeliveryAttempts: 5 where the ADR's GCP mechanism needs the policy (A-1), and a
handler that defers on every delivery, When the message is delivered three times — the test
quitting and awaiting the pump once the dispatch count (R-27(c)(1)) for that message reaches 3 —
Then the delivery count presented on each delivery, as recorded by R-27(c)(6)'s recording
consumer, is strictly greater than the count presented on the previous delivery.
AC-42 (R-1) — the expiry path. Given, on each transport in scope — and on GCP, for both
consumer classes, GcpPullMessageConsumer and GcpPubSubStreamMessageConsumer (R-13) — a
subscription whose lease is short enough to lapse within the test, set through the knob that
actually governs it for that consumer: SQS, the queue's visibility timeout; GCP pull, the
subscription's AckDeadlineSeconds (GcpPullMessageConsumer does a raw Pull and never extends
the deadline); GCP stream, the configuration the ADR records under R-13's stream-consumer input (see
"The GCP stream consumer's expiry redelivery is an ADR input" under R-13); RocketMQ, the invisible
duration — no native redrive limit at or below 2, on GCP a DeadLetterPolicy with
MaxDeliveryAttempts: 5 where the ADR's GCP mechanism needs the policy (A-1), and a message
published with HandledCount = 0, When the test receives the message through the transport's
consumer, neither acknowledges, rejects nor requeues it, waits for that timeout to lapse, and
receives it again — through Receive and, separately, ReceiveAsync (NFR-8) — Then the second
delivery presents a delivery count strictly greater than the first. No pump and no Requeue call
is involved, so a count advanced only inside Requeue fails this criterion. On RocketMQ this is the
lease-lapse redelivery AC-23 measures; AC-42 asserts what AC-23 records. On GCP / Stream and
GCP / StreamOrdering the procedure that drives the lapse and the second receive — and completes
the deliveries the test holds — is the one the ADR records under R-13, not one this criterion
prescribes; the obligation (a strictly greater count on the expiry redelivery) is unchanged. Where
the ADR records an alternative test for a stream configuration instead (R-13), AC-42 is met for that
configuration when that alternative test passes.
AC-2 (R-2, R-23; see C-7) — Given a message published with HandledCount = 0 to any transport
in scope, When it is delivered for the first time, Then the consumer presents
Header.HandledCount == 0, and the conformance identity assertion comparing sent and received
HandledCount passes.
AC-3 (R-4) — Given, on each transport in scope and in both variants, requeueCount: 3, a
deadLetterRoutingKey whose destination exists or is created by the dead-letter producer (so the
rejection routes and R-4's exception does not arise), a handler that always defers, and no native redrive limit at or below 3 —
on GCP, a DeadLetterPolicy with MaxDeliveryAttempts: 5 (C-6's floor; where the ADR's GCP
mechanism needs the policy, a subscription without one is AC-11's R-11 case, not AC-3's; where it
does not, AC-3 also applies to a GCP subscription with no DeadLetterPolicy) —
When the pump runs, Then the handler is invoked at
most 3 times, a rejection with RejectionReason.DeliveryError is issued exactly once, and the
handler invocation count for that message still stands at 3 or fewer when the FR-23 observation
window closes — the instant the pump has been quit and awaited, which the template already reaches
immediately after its 60 s poll, so no additional wait is introduced (R-4, R-27(c)(3)).
AC-4 (R-5) — Given the run in AC-3, When the dead-letter destination is read, Then the
message is present, its Header.HandledCount is >= 2, and its bag carries rejectionReason == "DeliveryError", a non-empty rejectionMessage, a parseable ISO-8601 rejectionTimestamp, an
originalTopic equal to the source topic, and an originalMessageType.
AC-5 (R-6) — Given, on each transport in scope and in both variants, requeueCount: -1, no
native redrive policy (SQS: no RedrivePolicy; GCP: no DeadLetterPolicy; RocketMQ: no broker-side
max-retry dead-lettering within the run), and a handler that always defers, When the pump runs for 60 s, Then the handler is invoked more than 3 times, no DeliveryError rejection is issued,
and reading the dead-letter destination returns MessageType.MT_NONE.
AC-6 (R-7) — Given, on each transport in scope and in both variants, requeueCount: 1 and a
handler that defers on its first invocation, When the pump runs, Then the handler is invoked exactly once, Requeue is never called for
that message, a DeliveryError rejection is issued, and the message is on the dead-letter
destination.
AC-7 (R-7, R-25) — Given a subscription configured requeueCount: 0 in an application that
calls .ValidatePipelines(throwOnError: true), When the host starts, Then startup succeeds,
and the validation result contains exactly one ValidationSeverity.Warning finding naming that
subscription, the value 0, and both -1 and 1 as the likely intents. And Given the same
application with requeueCount: 3, When the host starts, Then the validation result contains
no zero-budget finding.
AC-33 (R-2, R-3, C-7) — Given the delivery-count normalisation the ADR specifies for a
transport whose broker counter originates at 1, When it is applied to the broker values 1,
2 and 3, Then it yields 0, 1 and 2 respectively; And Given it is applied to an
absent counter or 0 (Pub/Sub on a subscription with no DeadLetterPolicy, A-1), Then it yields
0; And the result is never negative.
0 is achieved on a
first delivery whose approximate counter exceeds its origin, or records the residual risk and the
cells exposed to it. This clause is a manual gate — a document review, not an assertion.
AC-34 (R-3) — AWSSQS, AWSSQS.V4, GcpPubSub and RocketMQ as exact or approximate, each with its
evidence, and no transport in scope is unclassified. This clause is a manual gate — a document
review, not an assertion. And Given each transport the table classifies exact, requeueCount: 4 and a handler that defers on every delivery, When three
deliveries occur, Then they present counts of exactly 0, 1 and 2.
AC-35 (R-7) — Given, on each transport in scope and in both variants, requeueCount: 0 and a handler
that defers on its first invocation, When the pump runs, Then the handler is invoked exactly
once, Requeue is never called for that message, a DeliveryError rejection is issued, and the
message is on the dead-letter destination. And Given requeueCount: -3, When the pump runs,
Then the same outcome holds — enforcement is enabled and the first deferral rejects. And Given
requeueCount: -1, When the pump runs, Then no rejection is issued (AC-5), confirming -1 is
the only value that disables the budget.
AC-41 (R-28, R-5) — Given, on each transport in scope and in both variants, a run in which the
budget is exhausted and Brighter routes the message to its dead-letter destination, When that
destination is read through the ordinary consumer path the conformance providers use — a real channel
from ChannelFactory, not a bespoke reader — Then the message presents
Header.HandledCount >= R - 1, and on the Brighter-managed route >= 3 for requeueCount: 3 —
specifically 3 on a transport the ADR's R-3 table classifies exact (AC-34); And it does not present the value the rejection destination's own delivery counter
would yield after R-2's normalisation, which for a first read of that destination is 0.
And Given the same read on a message a native redrive policy moved with no Brighter call (AC-9's
run), Then no count is asserted — R-28 does not bound that case.
AC-8 (R-8) — Given an SQS subscription with requeueCount: 3 and
RedrivePolicy(maxReceiveCount: 5) and a handler that always defers, When the pump runs,
Then the message reaches the Brighter-managed dead-letter destination carrying
rejectionReason == "DeliveryError", and reading the native redrive target returns
MessageType.MT_NONE.
AC-9 (R-8, R-9) — Given an SQS subscription constructed by the test (not taken from a
conformance gateway provider, whose values R-27 fixes at R = 3, M = 5) with requeueCount: 10 and
RedrivePolicy(maxReceiveCount: 3) and a handler that always defers, When the pump runs,
Then the message reaches the native redrive target and the message found there carries none
of the keys rejectionReason, rejectionMessage, rejectionTimestamp, originalTopic,
originalMessageType.
AC-10 (R-10, R-25) — Given a subscription constructed by the test (not taken from a
conformance gateway provider, whose values R-27 fixes at R = 3, M = 5) with requeueCount: 5 and a
native redrive limit of 5, in an application that calls .ValidatePipelines(), When the host starts, Then the
validation result contains a ValidationSeverity.Warning finding naming the subscription, 5 as
the budget, 5 as the native limit, and the statement that the effective limit is the native one;
and startup is not blocked. And Given requeueCount: 3 with a native limit of 5, When the
host starts, Then the validation result contains no such finding.
AC-11 (R-11, R-25, R-26) — Given a subscription carrying requeueCount: 3 of one of the
shapes the ADR names under R-25 as tripping R-11 — at minimum, if the ADR selects a counter-based
GCP mechanism, a GcpPubSubSubscription with no DeadLetterPolicy (A-1) — When the
host starts with .ValidatePipelines(), Then the validation result contains a
ValidationSeverity.Warning finding naming the subscription, the value 3, and the reason.
And Given the same subscription in an application that does not call .ValidatePipelines(),
When the channel is created, Then exactly one Warning is logged naming the subscription, the
value 3, and the reason, and no further such Warning is logged for that channel however many
messages are received.
And Given instead that the ADR states under R-25 that no subscription shape trips R-11 — which
R-25 explicitly permits — When R-11's rule and R-26's channel-creation log are nonetheless
implemented, Then each is exercised against a subscription that answers R-11's predicate
negatively and one that answers it positively, the negative producing no finding and no log line and
the positive producing exactly one of each; And the ADR's statement is recorded as the evidence
that no in-scope transport supplies a positive case in a real configuration.
R-11 is the anti-silence requirement (R-26), so it is built and verified on both branches: "no
shape trips it today" is not a licence to leave it unimplemented, because the predicate is what a
future transport answers.
AC-12 (R-12, R-23) — Given each of the eight configurations AWS / SnsStandard,
AWS / SnsFifo, AWS / SqsStandard, AWS / SqsFifo, AWS.V4 / SnsStandard, AWS.V4 / SnsFifo,
AWS.V4 / SqsStandard, AWS.V4 / SqsFifo, When the scoped conformance suite is run against
LocalStack in both variants, Then conformance behaviour FR-23 passes, and every behaviour column
that read Pass or Fixed before this spec still does.
AC-13 (R-12, NFR-6) — Given the FR-23 run of AC-12, When the observable outcomes of the
v3 and v4 packages are compared for the same configuration, Then these are identical: the number
of deliveries only if the ADR's R-3 table (AC-34) classifies SQS exact — otherwise each
package's count is <= R and the two are not compared for equality, because an approximate counter
may reject on different deliveries in separately provisioned queues (R-4) — the rejection reason, the destination kind (Brighter-managed dead-letter versus native
redrive target), the set of rejection-metadata keys present, the values of rejectionReason,
rejectionMessage and originalMessageType, and the Warning messages.
And these are instance-specific and compared for presence and shape only, never for equality:
originalTopic and the destination's own name, because the two packages run against separately
provisioned topics and queues; and rejectionTimestamp, which differs on every run.
AC-14 (R-15) — Given a GcpPubSubSubscription constructed with a deadLetterRoutingKey and
an invalidMessageRoutingKey, When it is assigned to IUseBrighterDeadLetterSupport and
IUseBrighterInvalidMessageSupport, Then both assignments compile and both routing keys read
back the configured values.
AC-15 (R-16, R-18) — Given each of the four GCP configurations, both consumers and both
variants, a deadLetterRoutingKey of <topic>.DLQ and an invalidMessageRoutingKey of
<topic>.Invalid, each of which exists or is created by the producer that routes to it, When Reject is called with each of Unacceptable, DeliveryError and
None, Then the message appears on <topic>.Invalid for Unacceptable and on <topic>.DLQ
for DeliveryError and None; in every case it carries originalTopic, originalMessageType,
rejectionReason and rejectionTimestamp, and carries rejectionMessage whenever a non-empty
description was supplied with the rejection reason (R-18) — so the None call made without a
description in R-16's example carries four keys, not five; in every case it carries no
ReceiptHandle key; and in every case a subsequent read of the source subscription returns
MessageType.MT_NONE.
AC-16 (R-16) — Given, on each of the four GCP configurations and both consumers, a
subscription with a deadLetterRoutingKey and no
invalidMessageRoutingKey, When Reject is called with RejectionReason.Unacceptable,
Then the message appears on the dead-letter destination.
AC-17 (R-17) — Given, on each of the four GCP configurations and both consumers, a
subscription with neither routing key, When Reject is called
with new MessageRejectionReason(RejectionReason.Unacceptable, "bad payload"), Then a Warning is
logged containing the message id and "Unacceptable", and a subsequent read of the source
subscription returns MessageType.MT_NONE.
AC-18 (R-19) — Given, on each of the four GCP configurations and both consumers, a
subscription with requeueDelay zero (so no RetryPolicy), a deadLetterRoutingKey naming a topic
that does not exist and is distinct from any DeadLetterPolicy topic, a dead-letter producer that
does not create it (the makeChannels it is built with is the ADR's to fix), a redelivery wait W
of 10 seconds, and on the pull consumer AckDeadlineSeconds: 60, When Reject is called with
RejectionReason.DeliveryError, Then an Error is logged naming the message id and
"DeliveryError", Reject returns true, the original is not acknowledged, the message is
delivered again within W, and the topic still does not exist afterwards. The last clause proves
the failure path ran rather than a created topic succeeding. Redelivery within W proves the
message was released by the consumer's requeue call (R-19) rather than left outstanding: an
outstanding pull message would not return before its 60 s deadline, and an outstanding stream
message would have its lease extended rather than return at all.
AC-43 (R-19, R-4, A-6) — Given, on each of the four GCP configurations and in both variants,
a subscription with requeueCount: 3, requeueDelay zero, a DeadLetterPolicy with
MaxDeliveryAttempts: 5 whose topic exists and carries a subscription the test reads, a
deadLetterRoutingKey whose routing publish fails as AC-18 induces it, and a handler chosen by
R-13's branch:
- on AC-19's branch, a handler that always defers, so the rejection is the budget's and the run exercises R-4's exception;
- on AC-40's branch, where no GCP mechanism advances the count and the budget cannot fire, a
handler that always throws
RejectMessageAction, so the rejection does not depend on the count. That run exercises R-19's loop, not R-4's exception, which cannot arise on that branch;
When a real pump runs while the test polls the DeadLetterPolicy topic's subscription every
500 ms, giving up at 60 s (NFR-7). Once the message is read there, the test reads the handler
invocation count, waits a further W of 10 s (AC-18's W), then quits and awaits the pump;
Then the message is read from the policy topic without rejection metadata (R-9); at least
one R-19 Error naming the message id and "DeliveryError" was logged; the deadLetterRoutingKey
topic still does not exist; the invocation count read after the quit and await is more than 3,
and equals the count read on arrival, so the message was not delivered again
from the source during W.
The Error and the absent topic prove that the failed-routing path ran. Without them, today's code
passes: its count is inert, the message is only ever requeued, and the native cap forwards it with
no Reject ever made. "More than 3" proves that the loop outlived the rejection that failed. It
relies on A-4's approximate counter jumping at most once before M = 5. M, A-6
is refuted on that configuration. AC-43 then records the refutation in conformance-status.md's GCP
paragraph in place of its arrival clause (A-6). It still asserts the rest: the R-19 Error, the
absent deadLetterRoutingKey topic, and the count past M, which is itself evidence that the loop
ran. Refutation is recorded per configuration: the pull consumer releases with
ModifyAckDeadline and the stream consumer with a streaming Nack, so A-6 may hold for one and not
the other. Not reading the message with the count at or below M is an ordinary failure, not a
refutation.
AC-39 (R-13, A-2 — measurement) — conformance-status.md's GCP paragraph;
(b) the ADR records whether A-2 held and, if it did not, which delivery_attempt-independent
mechanism it selected in consequence, or that none fits; (c) the ADR records either a GCP mechanism that satisfies R-1
to R-5 within NFR-1 to NFR-3, or that none does; (d) exactly one of AC-19 and AC-40 is then claimed,
selected by (c), and the other is recorded as not applicable with (c) and this measurement as the
reason.
AC-19 (R-13 — the ADR records a satisfying GCP mechanism) — Given the ADR records a GCP
mechanism that satisfies R-1 to R-5 within NFR-1 to NFR-3 — whether that is the broker counter,
because AC-39 recorded it populated and strictly advancing, or a delivery_attempt-independent
mechanism selected because AC-39 refuted A-2 — And Given each of GCP / Pull, GCP / PullOrdering, GCP / Stream,
GCP / StreamOrdering with requeueCount: 3 and a DeadLetterPolicy with
MaxDeliveryAttempts: 5, in both variants, When a handler that always defers is driven by a
real pump, Then the handler is invoked at most 3 times and the message reaches the
Brighter-managed dead-letter destination carrying rejectionReason == "DeliveryError".
AC-40 (R-13 — the ADR records that no GCP mechanism satisfies) — Given the ADR records that
no mechanism satisfies R-1 to R-5 for GCP within NFR-1 to NFR-3, whatever AC-39 measured — the
ordinary route to which is AC-39 recording a counter that is unpopulated, or populated but not
strictly advancing across redeliveries (e.g. the sequence 1, 1, 1), but a satisfying mechanism can
also fail to exist after a good measurement, e.g. where the counter advances but R-2's exact 0
cannot be reached on an elevated first delivery — When a GCP channel is created with
requeueCount: 3, Then R-11's Warning is logged naming the subscription and the blocker;
And the four GCP / * FR-23 cells read Deferred re-pointed at the emulator limitation rather
than at #4240; And AC-39's measurement is recorded in conformance-status.md's GCP paragraph;
And GcpPubSubSubscription still implements both support interfaces, so R-15 to R-19 are
unaffected and their twenty cells still move.
AC-19 and AC-40 partition every outcome, and the selector is the ADR's conclusion rather than AC-39's measurement. The ADR either records a mechanism that satisfies R-1 to R-5 within NFR-1 to NFR-3 (AC-19) or records that none does (AC-40); AC-39(b) already requires it to say which, so exactly one applies and neither can be claimed alongside the other. The measurement chooses which mechanism the ADR can reach for, not which AC governs — which is why a counter that is populated and advancing does not by itself entitle the four cells to move, and why a refuted A-2 does not by itself condemn them.
AC-20 (R-20, NFR-5) — Given the Pub/Sub emulator with PUBSUB_EMULATOR_HOST set and a
subscription carrying a DeadLetterPolicy with neither DeadLetter.PublisherMember nor
SubscriberMember configured, When a channel is created, Then channel creation succeeds and
returns a usable channel; the GetProjectAsync step's tolerated outcome — an Unauthenticated
or PermissionDenied status, or a failure to construct the Resource Manager client, whichever
the ambient credentials produce — is tolerated in both
UpdateIAmRoleForDeadLetterAsync and UpdateIAmRoleForSubscriptionAsync; each abandons its binding
at that point so GetIamPolicyAsync is not reached; and exactly two Warnings are logged, one per
helper, each carrying R-20's five elements.
Which of the two shapes occurs is decided by ambient credentials, not by this spec, so AC-20 must not hard-assert the status.
GcpMessagingGatewayConnection.CreateProjectsClientAsync(:173) buildsnew ProjectsClientBuilder { Credential = Credential }, which resolves Application Default Credentials whenCredentialis null: where ADC resolve,GetProjectAsyncleaves and is refused with a status (spec 0036 measuredUnauthenticated); where they do not — an ordinary laptop or CI agent running only the emulator, which is R-21's bar — construction throws and no RPC is issued at all. R-20 tolerates both on the same terms, and both must reach the same observable outcome. And Given the same subscription with both members configured (C-11), When a channel is created, Then no Resource Manager call is issued;GetIamPolicyAsync'sUnimplementedis tolerated once per helper; exactly two Warnings are logged; and channel creation succeeds.
AC-21 (R-20, NFR-5) — Given the status-code filter R-20 mandates, exercised directly as a
unit test rather than through a broker, When it is presented with an RpcException carrying
NotFound, InvalidArgument, DeadlineExceeded or ResourceExhausted, Then the exception is
rethrown unchanged and nothing is logged as tolerated; And When it is presented with
Unimplemented, PermissionDenied or Unauthenticated, Then it is tolerated and one Warning
carrying R-20's five elements is logged.
And Given R-20's client-construction case — a Resource Manager client that cannot be constructed
because credentials do not resolve, so there is no RpcException to inspect — When it is
exercised directly, Then it is tolerated, one Warning carrying R-20's five elements is logged,
the type caught is the one the ADR names (NFR-5), and nothing wider is caught.
A broker-level negative case is not available and must not be written: when
DeadLetter != null,EnsureSubscriptionExistsAsynccreates the dead-letter topic and subscription withOnMissingChannel.Createbefore the IAM helpers run (GcpPubSubMessageGateway.cs:220-236), soNotFoundcannot arise on that path; the emulator returns only tolerated codes; and C-10 forbids mocking a transport for isolation. Exercising the filter directly is what keeps R-20's "no other status code is swallowed" clause, and NFR-5, verifiable at all.
AC-22 (R-21) — Given a clean emulator (docker-compose -f docker-compose-gcp.yaml down -v
then up -d), When the GCP conformance behaviours FR-4, FR-5, FR-6, FR-8 and FR-17 (the
rejection-routing behaviours AC-15 exercises) are run twice across all four GCP configurations and
both variants with a clean store between runs — together with FR-23 (AC-19) whenever AC-19 is the
branch that applies, which the ADR's recorded conclusion selects rather than AC-39's measurement (see
the note under AC-40) — Then both runs produce the same result, with no gcp-ci involvement.
These are the same behaviours AC-30's GCP rows move.
AC-23 (R-14, condition) — RocketMQ / RocketMQMessagingGateway with requeueCount: 3
and a handler that always defers, When the message is delivered three times over roughly 30 s of
lease lapses with no ChangeInvisibleDuration call, Then the broker-supplied delivery-attempt
value observed on each delivery is recorded.
AC-23 is a measurement, not a pass/fail gate — both outcomes are legitimate, and the measurement
selects which of AC-24 and AC-25 applies. Its own exit criteria are assertable and all three are
required: (a) the three observed values are written into conformance-status.md's RocketMQ
paragraph; (b) #4353 is updated with the same measurement; (c) exactly one of AC-24 and AC-25 is
then claimed, and the other is recorded as not applicable with AC-23's measurement as the reason.
AC-24 (R-14, condition holds) — Given AC-23 recorded a strictly increasing sequence, When
the FR-23 behaviour is run in both variants against docker-compose-rocketmq.yaml from a store
cleaned with down -v, Then the message reaches the dead-letter destination inside the 60 s
window carrying rejectionReason == "DeliveryError", and the RocketMQ / RocketMQMessagingGateway
FR-23 cell reads Fixed.
AC-25 (R-14, condition does not hold) — Given AC-23 recorded a non-increasing sequence,
When a RocketMQ channel is created with requeueCount: 3, Then a Warning is logged naming the
subscription and the blocker; And the RocketMQ / RocketMQMessagingGateway FR-23 cell reads
Deferred pointing at the upstream ChangeInvisibleDuration blocker rather than at #4353; And
RocketMqSubscription still implements both support interfaces.
AC-26 (R-22, R-23) — Given the nine configurations Redis / RedisMessagingGateway,
Kafka / Classic, Kafka / Consumer, Kafka / PartitionKey, MSSQL / MSSQLMessagingGateway,
PostgresSQL / PostgresMessagingGateway, RMQ.Async / Classic, RMQ.Async / Quorum,
RMQ.Sync / RmqSyncMessagingGateway, When their scoped conformance suites are run in both
variants against their compose files, Then FR-23 passes on all nine and no other cell that read
Pass or Fixed regresses.
AC-27 (R-24) — Given the five compile-only V10 compatibility samples R-24 requires, each
committed in the project R-24's table names — tests/Paramore.Brighter.Core.Tests/ for
Subscription, MessageHeader and Message; tests/Paramore.Brighter.AWS.Tests/,
tests/Paramore.Brighter.AWS.V4.Tests/, tests/Paramore.Brighter.Gcp.Tests/ and
tests/Paramore.Brighter.RocketMQ.Tests/ for SqsSubscription, SqsSubscription (v4),
GcpPubSubSubscription and RocketMqSubscription respectively — each exercising its types with the
argument shapes a V10 application uses today, no sample containing #pragma warning disable, no
new project created, and no test project having gained a reference, When the solution is built against the post-change
assemblies, Then it compiles with no new errors and no new obsoletion warnings — and a breaking
change fails the build rather than awaiting review.
AC-28 (NFR-1, NFR-3) — Given the diff for each transport in scope, When the broker-call
sites on its receive and requeue paths are enumerated from that diff and compared with the same
enumeration on the base revision, Then the per-delivery and per-requeue call counts are equal,
and the enumeration for each of the four transports is recorded in the ADR.
AC-29 (NFR-4) — Given a channel whose budget will not behave as its author probably intended (the conditions of R-7, R-10 or R-11), When 100 messages are received on it, Then no Warning about the budget is logged on the receive path at all, and the count of channel-creation budget Warnings for that channel stands at exactly one if R-11's condition holds and zero otherwise — R-26 gives the channel-creation route to R-11 alone. And Given the same channel, When the count is taken again after a further 100 messages, Then it is unchanged. Validation findings are not counted here: AC-7, AC-10 and AC-11 each assert their own rule's single finding, and a subscription that trips two rules is expected to produce two findings.
AC-36 (R-27) — Given the eight AWS/AWS.V4 gateway providers and the four GCP gateway
providers named in R-27, When their configured values are read, Then every one satisfies
R < M: requeueCount: 3 against a native limit of 5 on both families; And Given the four GCP
providers, When they are read on the emulator, Then each sets DeadLetter.PublisherMember and
SubscriberMember to a non-empty serviceAccount:…-shaped string, and on real Pub/Sub leaves both
unset (R-27(b) amendment). And Given an FR-23 run on any of the twelve, When the budget is
exhausted, Then the message reaches the Brighter-managed dead-letter destination carrying
rejectionReason == "DeliveryError" — the native redrive target is not the route taken.
And Given R-27(c), When any generated FR-23 test is run, Then it asserts the harness's
dispatch count for the message under test, and a run in which the message was dispatched more times
than its budget allows fails on that assertion rather than passing on DLQ arrival alone.
AC-37 (NFR-2) — Given a consumer on each transport in scope, When 1,000 messages are
received on the base revision and on the post-change revision, and the allocated-bytes delta per
received message is compared between them (e.g. by GC.GetAllocatedBytesForCurrentThread() around
the receive loop), Then the post-change figure is not greater than the base figure on every
transport in scope. The tolerance is zero — NFR-2's budget is "no increase", not "no significant
increase". To keep that assertable against allocation noise, the figure compared is the median of
five runs of 1,000 messages on each revision, and the comparison is post <= base on those
medians.
AC-38 (R-22, R-23) — Given RocketMQ / RocketMQMessagingGateway, whose FR-4, FR-5, FR-6,
FR-7, FR-8, FR-9, FR-16, FR-17 and FR-22 cells currently read Fixed, When its scoped
conformance suite is run in both variants against docker-compose-rocketmq.yaml from a store cleaned
with down -v, Then all nine still pass — on both branches of R-14, because the
"bound but unimplemented" branch still touches RocketMessageConsumer's receive path if the ADR
normalises counts there.
AC-30 (all) — Given the work is complete, When
specs/0036-universal-transport-conformance-tests/conformance-status.md is read, Then the
following cells have moved, each on recorded evidence:
| Cells | From | To | Evidence |
|---|---|---|---|
FR-23 × 8: AWS / SnsStandard, AWS / SnsFifo, AWS / SqsStandard, AWS / SqsFifo, and the four AWS.V4 twins |
Deferred -> #4341 |
Fixed (#4341) |
LocalStack, both variants |
FR-4, FR-5, FR-6, FR-8, FR-17 × GCP / Pull, GCP / PullOrdering, GCP / Stream, GCP / StreamOrdering — 20 cells |
Deferred -> #4240 |
Fixed (#4386) |
Pub/Sub emulator, both variants |
| FR-23 × the four GCP configurations — 4 cells | Deferred -> #4240 |
Fixed (#4386) if the ADR records a GCP mechanism that satisfies R-1 to R-5 within NFR-1 to NFR-3 (AC-19); otherwise Deferred re-pointed at the emulator limitation (AC-40, R-13) |
Pub/Sub emulator, both variants |
FR-23 × RocketMQ / RocketMQMessagingGateway — 1 cell |
Deferred -> #4353 |
Fixed (#4353) if R-14's condition holds; otherwise Deferred re-pointed at the upstream blocker |
Local RocketMQ, both variants |
33 cells in total: 28 unconditional — the 8 AWS/AWS.V4 FR-23 cells and the 20 GCP rejection-routing cells, which depend on R-20 and not on A-2 — and 5 conditional: the 4 GCP FR-23 cells on the ADR's recorded conclusion about a satisfying GCP mechanism (R-13; A-2's measurement chooses which mechanisms it can reach for), and the 1 RocketMQ FR-23 cell on R-14's condition. Each conditional cell has a defined outcome on both branches, so "conditional" never means "unresolved".
AC-31 (honesty) — Given the work is complete, When the ledger is read, Then it states explicitly which cells this spec could not move and why:
MQTT / MqttMessagingGatewayFR-23 — aProactordeadlock (#4351), a different root cause.AzureServiceBus / AzureServiceBusMessagingGatewayFR-23 and its otherDeferredcells — no local emulator exists; ASB dead-letters natively and is not in this defect family.- The remaining GCP cells (FR-2, FR-7, FR-9, FR-15, FR-16, FR-22 × 4 configurations) — R-20 may make them locally runnable for the first time, but they move only on their own evidence, and any defect such a run reveals gets its own issue.
AWS / SqsFifoandAWS.V4 / SqsFifoFR-9,MSSQLFR-16,RedisFR-16,MQTTFR-16,RMQ.*FR-5,RocketMQFR-2 and FR-15 — unrelated to this family. (MQTT / MqttMessagingGatewaytherefore appears twice in this list: FR-16 here, FR-23 above.)- On each configuration where A-6 was refuted — R-19's native-cap bound, which is then unevidenced on the emulator and rests on the service's documented behaviour (R-21's accepted exception). This is a requirement, not a cell, and is stated in the GCP paragraph beside AC-43's refutation record.
AC-32 (R-25, R-26) — Given an application that calls .ValidatePipelines(throwOnError: true)
and whose only findings are budget-configuration findings from R-7, R-10 or R-11, When the host
starts, Then the host starts successfully — no PipelineValidationException is thrown — because
all three are ValidationSeverity.Warning, and Warning findings never block startup regardless of
throwOnError.
Every requirement maps to at least one acceptance criterion.
| Requirement | Acceptance Criteria |
|---|---|
| R-1 | AC-1, AC-42 |
| R-2 | AC-2, AC-33 |
| R-3 | AC-33, AC-34 |
| R-4 | AC-3, AC-43 |
| R-5 | AC-4, AC-41 |
| R-6 | AC-5 |
| R-7 | AC-6, AC-7, AC-35 |
| R-8 | AC-8, AC-9, AC-10 |
| R-9 | AC-9 |
| R-10 | AC-10 |
| R-11 | AC-11 |
| R-12 | AC-12, AC-13 |
| R-13 | AC-19, AC-39, AC-40 |
| R-14 | AC-23, AC-24, AC-25 |
| R-15 | AC-14 |
| R-16 | AC-15, AC-16 |
| R-17 | AC-17 |
| R-18 | AC-15 |
| R-19 | AC-18, AC-43 |
| R-20 | AC-20, AC-21 |
| R-21 | AC-22 |
| R-22 | AC-26, AC-38 |
| R-23 | AC-2, AC-12, AC-26, AC-38 |
| R-24 | AC-27 |
| R-25 | AC-7, AC-10, AC-11, AC-32 |
| R-26 | AC-11, AC-32 |
| R-27 | AC-36 |
| R-28 | AC-41 |
| NFR-1 | AC-28 |
| NFR-2 | AC-37 |
| NFR-3 | AC-28 |
| NFR-4 | AC-29, AC-32 |
| NFR-5 | AC-20, AC-21 |
| NFR-6 | AC-13 |
| NFR-7 | AC-12, AC-19, AC-24 |
| NFR-8 | AC-1, AC-12, AC-15, AC-19, AC-26 |
AC-30 (ledger movement) and AC-31 (what this spec could not move) are deliberately not mapped to a single requirement: they are cross-cutting exit criteria for the spec as a whole, and every transport requirement in Groups C, D and E contributes evidence to them.
Which criteria and outcomes a green suite does not prove. These are manual gates, each marked
- AC-23 and AC-39 — measurements that select a branch; both outcomes are legitimate, so neither can fail.
- AC-28 — a recorded enumeration of broker calls.
- AC-43's arrival clause, only on a configuration where A-6 is refuted: the refutation is then a ledger record, not an assertion. AC-43's other clauses are still asserted.
- The failed-acknowledgement outcome (R-16, R-17) and R-19's failed-release outcome. A broker cannot produce either failure on demand, so no AC asserts them. The ADR's record under R-19's ADR input is verified at design review, and the evidence it records is produced at implementation. These are requirement outcomes, not AC clauses.
- AC-30 and AC-31 — ledger edits.
- The ledger clauses of AC-24, AC-25 and AC-40 — "the cell reads
Fixed", "the cell readsDeferredpointing at the upstream blocker", "the fourGCP / *FR-23 cells readDeferredre-pointed …" and "AC-39's measurement is recorded inconformance-status.md". The behavioural clauses of those three ACs are ordinary assertions; only their ledger clauses are gates. - AC-33's ADR-review clause, AC-34's first clause and AC-41's final clause — document reviews of the ADR's first-delivery statement, its exact/approximate classification table, and its R-28 table. Every behavioural clause of AC-41 is an ordinary assertion; only that last clause is a gate.
Every other clause of every other AC is an assertion a test can make.
Assertable, but not writable until the ADR exists — these read a decision the ADR must first
record, so the test can be written only after design: AC-11's Given (the subscription shapes that
trip R-11), AC-18's and AC-43's Given (the makeChannels the GCP dead-letter producer is
built with), AC-21's client-construction clause (the exception type the ADR names, NFR-5),
AC-33's first clause (the normalisation the ADR specifies), AC-34's second
clause (the transports the ADR's table classifies exact), and AC-42 on GCP / Stream and
GCP / StreamOrdering (the procedure the ADR records under R-13's stream-consumer input, or the
alternative test it records there instead).
AC-19 and AC-40 are a third kind: both are writable now, but which one is claimed is
selected by the ADR's recorded conclusion rather than by a measurement (see the note under AC-40).
All three defects were surfaced by spec 0036's conformance behaviour FR-23 (requeue budget
exhausted to DLQ), which is the only behaviour in that suite that drives a real Brighter pump
rather than calling Requeue on the channel. The budget is enforced in the pump and nowhere else, so
a test that requeues the channel directly cannot reach the code it is named for, however green it
goes. FR-22 requeues from the test's own thread and passes on all three transports; FR-23 is the
composition that fails.
On SQS the evidence was the absence of metadata: the message did reach the DLQ, but as
handledCount=0 with no rejectionReason. RefreshMetadata stamps that metadata inside
RejectAsync, so its absence proved Brighter had never rejected the message — SQS redrive had moved
its own stored copy. R-9 preserves that diagnostic deliberately.
On RocketMQ the absence was total, and timing was ruled out by measurement rather than argument: at
a 30 s ceiling and again at a 150 s ceiling (roughly fifteen redeliveries against a budget of three),
the dead-letter poll returned MT_NONE. The budget does not run down slowly; it does not run down
at all.
SqsMessageConsumer.RequeueAsync(:384; V4:377) —ChangeMessageVisibilityAsync, stored message never rewritten.handled-countis written only on send (SqsMessageSender.cs:130,SnsMessagePublisher.cs:110); a visibility change is not a send.GcpPullMessageConsumer.Requeue/RequeueAsync(:335,:369) —ModifyAckDeadline(…, 0), same shape.GcpPubSubStreamMessageConsumer.Requeue(:217) —GcpStreamMessage.Reject(), same shape.RocketMessageConsumer.Requeue(:179) — resolves theMessageViewand returnstrue; the one broker call is commented out pending an upstream C# client fix.GcpPullMessageConsumer.Reject/RejectAsync(:276,:306) andGcpPubSubStreamMessageConsumer.Reject(:84) — acknowledge and discard;reasonbound, never read.GcpPubSubMessageGateway.EnsureSubscriptionExistsAsync(:235,:251) — the two IAM helpers R-20 covers.
ADR 0038-aws-sqs-dlq-direct-send (SQS), 0039-redis-dlq-brighter-managed (Redis) and
0041-postgres-dlq-brighter-managed (Postgres) are the three decisions that
settled Brighter-managed dead-lettering for destructive-ack transports. R-15 to R-19 take the same
decision again for GCP, and the argument for taking it again is the same one: Pub/Sub's
DeadLetterPolicy dead-letters on MaxDeliveryAttempts exhaustion, not on an explicit reject — and
an explicit reject acks, which is precisely the action that tells Pub/Sub the message was handled
successfully and stops the delivery count ever reaching the threshold. GCP has neither a native nor
a Brighter-managed route today. RMQ.Async, RMQ.Sync and Azure Service Bus are absent from the
support interfaces too, but they have an answer — they dead-letter natively — which is why their
FR-4/6/17 cells pass on routing alone under spec 0036's FR-8 relaxation. GCP has no such answer, and
that is the difference this spec closes.
RocketMessageConsumer.ReadDelay (~:433) reads HeaderNames.HandledCount where it means to read a
delay header:
static TimeSpan ReadDelay(MessageView message)
{
if (message.Properties.TryGetValue(HeaderNames.HandledCount, out var delayString)
&& TimeSpan.TryParse(delayString, out var delay))A copy-paste defect, adjacent to ReadHandledCount (:422) which this spec does touch. It is
not folded into scope — it is a different behaviour with a different blast radius — but it is
recorded here so it is not lost. It should get its own /bugfix.
Spec 0036's FR-7 carried an obligation whose prose was order-free but whose worked example said
"send M1 then M2". The generator template acquired an ordering assumption from the example and it
became a real bug. Every example above has therefore been checked against the obligation
immediately preceding it, and marked as illustrative where the risk was highest. Two places where a
detail was deliberately lifted into the obligation rather than left in an example: R-3's
exact-versus-approximate distinction (otherwise R-1's 0, 1, 2 example would have quietly narrowed
R-1 to exact counters), and R-4's "at most R" (otherwise the same example would have narrowed a
bound into an equality). One place where a detail was deliberately stripped: R-1's example says
nothing about wall-clock spacing between deliveries, because the obligation does not constrain it
and RocketMQ's 10 s lease would have been read as a requirement if it had appeared there.