Bugfix: Canonical FR-7 and FR-16 two-message arms assume broker delivery order, so they fail on unordered SNS/SQS Standard queues
Linked Issue: (none — surfaced by CI on 2e650c36e)
Status: ✅ Verified end to end — aws-ci green on f7ad18d6f with every arm RUN (0 skipped, 0 failed)
CI run 35220513183, head SHA 2e650c36e2c5225172debca14b69542b5f93f240, branch feature/4240-universal-transport-conformance-tests: the aws-ci job is RED. Every failure is one of exactly two generated canonical arms:
When_nacking_first_of_two_messages_should_redeliver_nacked_then_receive_second(FR-16)When_rejecting_message_with_no_channels_configured_should_acknowledge_and_log(FR-7)
in Paramore.Brighter.AWS.Tests.MessagingGateway.SnsStandard.{Reactor,Proactor} and ...SqsStandard.{Reactor,Proactor}.
Expected: the arm receives its two messages in send order. Observed: it receives the other of its own two messages.
Assert.Equal() Failure: Values differ
Expected: 01a0af59-8f26-7797-be9c-6475addab1e7
Actual: 01a0af59-8f26-72f6-8f24-56b5c8a1636a
at AwsMessageAssertion.Assert(...) AwsMessageAssertion.cs:line 49
at ...SnsStandard.Proactor....When_nacking_first_of_two_messages_...() line 139
AwsMessageAssertion.cs:49 is Xunit.Assert.Equal(expected.Header.MessageId, actual.Header.MessageId). Both ids are UUIDv7 stamped in the same millisecond — they are the arm's own two messages. Nothing was lost; the wrong one arrived first.
Two discriminators, both measured:
- Only the
Standardvariants fail. EverySnsFifo/SqsFifocopy of the same two arms passes in the same job. - Non-deterministic. The
aws-cijob runs two projects back to back —Paramore.Brighter.AWS.TeststhenParamore.Brighter.AWS.V4.Tests(.github/workflows/ci.yml:560-562), the same generated arms against the two AWS SDK majors. The failing sets differ between them (Failed: 4, thenFailed: 3), andSqsStandard.Proactornack isPassedin one andFailedin the other.
A third discriminator, from the workflow itself: aws-mock-ci is green. It runs the same generated Standard arms against an in-process LocalStack-compatible emulator (floci/floci:1.5.19, .github/workflows/ci.yml:478-494), while aws-ci (.github/workflows/ci.yml:524) runs them against a real AWS account in eu-west-1. Same code, same configuration; passes on the emulator, flaky on the real broker.
Reproduction route: run dotnet test ./tests/Paramore.Brighter.AWS.Tests/Paramore.Brighter.AWS.Tests.csproj --filter "Fragile!=CI" against a real AWS account (not the emulator), repeatedly. The two arms fail intermittently on the four *Standard namespaces only.
Templates are the source of truth; the generated .cs files are outputs.
FR-16 — nack, two-message arm (Proactor template)
tools/Paramore.Brighter.Test.Generator/Templates/MessagingGateway/Proactor/When_nacking_a_message_it_should_be_redelivered.cs.liquid
:117-118— two sequential sends,nackedMessagethenfollowingMessage:121— first receive, assumed to benackedMessage(never asserted to be):139—_messageAssertion.Assert(nackedMessage, redelivered);← the assertion in the stack trace:159—_messageAssertion.Assert(followingMessage, receivedFollowing);
FR-16 — nack, two-message arm (Reactor template)
.../Templates/MessagingGateway/Reactor/When_nacking_a_message_it_should_be_redelivered.cs.liquid
:111-112sends,:115first receive,:133first identity assertion,:153second.
FR-7 — no-channels rejection (Reactor template)
.../Templates/MessagingGateway/Reactor/When_rejecting_message_with_no_channels_configured_should_acknowledge_and_log.cs.liquid
:68-69sendsrejectedMessagethenfollowingMessage:72first receive, assumed to berejectedMessage:75rejects whatever arrived:97_messageAssertion.Assert(followingMessage, receivedFollowing);
FR-7 — no-channels rejection (Proactor template)
.../Templates/MessagingGateway/Proactor/When_rejecting_message_with_no_channels_configured_should_acknowledge_and_log.cs.liquid — :74-75, :78, :81, :103.
Rendered copies (outputs, do not hand-edit)
tests/Paramore.Brighter.AWS.Tests/MessagingGateway/SnsStandard/Generated/Proactor/When_nacking_a_message_it_should_be_redelivered.cs:139— verified to be_messageAssertion.Assert(nackedMessage, redelivered);, matching the trace..../SnsStandard/Generated/Reactor/When_rejecting_message_with_no_channels_configured_should_acknowledge_and_log.cs:72(first receive) and:97(terminal identity assertion).
Assertion helper
tests/Paramore.Brighter.AWS.Tests/AwsMessageAssertion.cs:49 (MessageId) and :60 (Body) — the two fields that make the arm's messages distinguishable, and the two the distinct-id commits deliberately made distinct.
Why FIFO behaves differently (harness, verified)
tests/Paramore.Brighter.AWS.Tests/test-configuration.json:20,43—SnsFifoandSqsFifoalone set"MessageBuilder": "FifoMessageBuilder";SnsStandard/SqsStandarduse the default builder.tests/Paramore.Brighter.AWS.Tests/FifoMessageBuilder.cs:45— the constructor stamps one partition key per builder instance. Each generated test class holds a single_messageBuilder, so both of an arm's messages carry the same partition key → the sameMessageGroupId.tests/Paramore.Brighter.AWS.Tests/MessagingGateway/FifoMetadataProducer.cs:73-76— the wrapper only supplies a group id when none is present, so it does not break that shared group;:81supplies a per-send dedup id.
Broker-side mechanism (verified)
src/Paramore.Brighter.MessagingGateway.AWSSQS/SqsMessageConsumer.cs:348—NackisChangeMessageVisibility(..., 0): it makes the message visible again and lets SQS choose what to hand out next. It does not push it to the head of a queue.SqsMessageConsumer.cs:264-272— with neither DLQ nor invalid channel,RejectcallsAcknowledge(delete) and returnstrue. The rejected message is genuinely gone, so FR-7's failure cannot be the rejected message "coming back".SqsMessageConsumer.cs:188-190— receive usesMaxNumberOfMessages = _batchSize, which isSubscription'sbufferSize, default1(src/Paramore.Brighter/Subscription.cs:200). One message per call, chosen by SQS.
Both arms assert a delivery order that the specification never requires and that an SQS/SNS Standard queue does not provide.
The ordering assumption sits in two places per arm, not one:
- FR-16: the unasserted assumption at template
:121/:115that the first receive yieldsnackedMessage, plus the asserted claim at:139/:133that the message returned afterNackis the nacked one rather than the one queued behind it. - FR-7: the unasserted assumption at template
:72/:78that the first receive yieldsrejectedMessage. BecauseRejectdeletes (SqsMessageConsumer.cs:271), if the first receive returnedfollowingMessagethenfollowingMessageis deleted and the only message left isrejectedMessage— so the terminal assertion at:97/:103fails with the other id. Same failure signature, different mechanism: FR-7's ordering assumption is upstream of its terminal assertion.
Against a real Standard queue: SQS Standard is documented as best-effort ordering and at-least-once delivery, and SNS fan-out to a Standard queue preserves no order either. With MaxNumberOfMessages = 1, each ReceiveMessage samples a subset of SQS servers, so which of two visible messages comes back is genuinely arbitrary per call.
This explains the FIFO/Standard split. FIFO passes because of genuine broker ordering, but only because the harness puts both of an arm's messages in one message group: FifoMessageBuilder.cs:45 stamps a single partition key per builder instance, and FifoMetadataProducer.cs:73-76 leaves it alone. A FIFO message group is strictly ordered and blocked while a message from it is in flight, so the first receive must be the first send, and the nacked message must be re-offered before the one behind it. FIFO therefore satisfies an assumption the template never states — it is not evidence that the assumption is a conformance obligation. (Equally: the same harness detail is what makes FIFO's pass fragile. Give the two messages different groups and FIFO would be free to reorder them too.)
This explains the non-determinism. The ordering is a coin flip per receive, so any given run fails a random subset of the eight Standard arms (4 namespaces × 2 SDK majors in one job), which is exactly the Failed: 4 / Failed: 3 pattern with SqsStandard.Proactor passing in one project and failing in the other. It also explains aws-mock-ci being green: the emulator is an in-memory, effectively FIFO implementation, so the coin always lands the same way there.
What the other transports' passes do and do not tell us. The conformance matrix (specs/0036-universal-transport-conformance-tests/conformance-status.md, Conformance Matrix) records FR-7 and FR-16 as Pass for Kafka ×3, RMQ ×3, Redis, Postgres, MSSQL, Azure Service Bus and GCP. Those brokers are ordered per partition/queue/subscription, so the passes confirm the behaviour under ordered delivery — they say nothing about whether the arm is a valid conformance test, because ordering is not a conformance requirement: requirements.md contains no ordering obligation anywhere (a grep for "order"/"ordering"/"at-least-once" finds only the fallback-ladder routing order at :76 and unrelated prose).
Other wired configurations that are unordered and therefore latently exposed to the same failure:
AWS.V4 / SnsStandardandAWS.V4 / SqsStandard— already failing; they are inside the sameaws-cijob, not a separate green one.GCP / PullandGCP / Stream— the non-ordering variants; onlyGcpPullOrderingMessageGatewayProvider.cs:139andGcpStreamOrderingMessageGatewayProvider.cs:139setEnableMessageOrdering = true. These run against the Pub/Sub emulator, which likely masks it the wayflocidoes for AWS.- RocketMQ normal topics and MQTT are further candidates, not verified here.
Is the ordering intrinsic to the behaviour? No, and the spec text says so twice over, though its examples embed the order:
FR-7 — "
channel.Reject(M, ...)returnstrue— the message is removed rather than redelivered — and the channel goes on to receive the next message without blocking." (requirements.md:176-180) FR-16 — "proves the nacked message is redelivered and the following message is not blocked behind it." (requirements.md:365-369)
Neither obligation mentions order. The illustrations do — FR-7's "Example: send M1 then M2; receive M1; ... the next receive yields M2" (requirements.md:182-183), AC-7 (requirements.md:634-636) and AC-17's "after nacking M the redelivered M is received and then M2 is received" (requirements.md:698-701). The arms encode the illustration rather than the obligation. The behaviour actually specified is set-shaped: both messages are received, the nacked one reappears, the rejected one does not, and neither blocks the other.
Other canonical arms of the same shape. Only these two templates send more than one message (verified: a count of send calls across all 42 messaging-gateway templates yields exactly these four files). The one other multi-message arm, When_a_message_consumer_reads_multiple_messages_should_receive_all_messages, sends four messages but is already order-insensitive — it looks the expected message up by id before asserting (.../Templates/MessagingGateway/Reactor/When_a_message_consumer_reads_multiple_messages_should_receive_all_messages.cs.liquid:74, Proactor :81). So there is no third latent arm, and the repo already contains the idiom for the fix.
Suggested remedy — UNVERIFIED, to be proven or refuted in /bugfix:confirm: rewrite both templates to assert the set rather than the sequence — identify each received message by id against the two sent messages (the FirstOrDefault(x => x.Header.MessageId == received.Header.MessageId) idiom at ..._multiple_messages...cs.liquid:74), then assert the FR-specific claims that do not depend on order: for FR-16, that the nacked message is among those received after the Nack and that the other message is also received; for FR-7, that Reject returns true on whichever message arrived first and that the other message is subsequently received. Two consequences to check before proposing that: (a) four generator golden tests pin the current text — tests/Paramore.Brighter.Test.Generator.Tests/CanonicalTemplates/When_generating_nack_test_should_emit_redelivery_and_two_message_variant_both_variants.cs:262 and :285, and .../When_generating_no_channels_reject_should_emit_ack_and_continue_both_variants.cs:214 and :237, all Assert.Contains("_messageAssertion.Assert(followingMessage, receivedFollowing)", content) — so they must move with the templates; (b) SQS Standard is also at-least-once, so a set-based rewrite that asserts "exactly once" would trade an ordering flake for a duplicate flake.
What would REFUTE this hypothesis. Instrument every receive in the two arms to log the received MessageId, and run them repeatedly against real AWS Standard queues. The hypothesis predicts that every failure coincides with a receive returning the other of the two ids the test sent, in send-order-violating position, and never a third id, a rewritten id, or a missing message. It is refuted if failures occur while the receives do return the ids in send order (which would mean the id mismatch comes from somewhere other than delivery order — e.g. the transport rewriting Header.MessageId on redelivery), or if a variant that removes the ordering freedom entirely — send followingMessage only after nackedMessage has been received and nacked — still fails on Standard at the same rate. It is also refuted if the FIFO arms fail at a comparable rate once enough runs accumulate, since that would mean strict ordering is not what is protecting them.
Commits febd0d7c6 and 2e650c36e gave each of the two messages in the FR-7 and FR-16 two-message arms its own MessageId and body. Before that, both messages were built from one DefaultMessageBuilder whose id/body are field initialisers, so both messages were byte-identical and every identity assertion passed no matter which message arrived. Making them distinguishable exposed a latent assumption the templates had always carried: that a queue delivers in send order and at most once. SNS/SQS Standard guarantees neither; SNS/SQS FIFO guarantees both, which is why only the *Standard arms are red and only intermittently.
⛔ MATERIAL CORRECTION TO THE TRIAGE. The triage claimed "the specification never requires" ordering. That is wrong. AC-7 and AC-17 are normative and both mandate the order:
AC-7 (FR-7) (
requirements.md:634-636) — "Given a channel with neither DLQ nor invalid channel and two queued messagesM1andM2, whenchannel.Reject(M1, DeliveryError)is called, then it returnstrueand the next receive yieldsM2." AC-17 (FR-16) (requirements.md:698-701) — "...given a second queued messageM2, after nackingMthe redeliveredMis received and thenM2is received."
The FR prose is looser — FR-7 (:176-181) requires only "returns true … and the channel goes on to receive the next message without blocking"; FR-16 (:368-369) only "the nacked message is redelivered and the following message is not blocked behind it" — but FR-7's Example (:183-184) re-imports the order, and the ACs are normative. So this is not a template-only fix: AC-7, AC-17 and FR-7's Example must be amended first, by whoever owns spec 0036.
⭐ The deeper defect: the spec nowhere states which wired configurations offer an ordering guarantee. That omission is what let an order-dependent AC be written for a suite that targets unordered transports.
[x] Code-trace — every file:line re-verified in this worktree. [ ] Red repro — none possible for the bug itself (needs real multi-node SQS; the floci emulator serialises delivery, which is why aws-mock-ci is green).
The stack-trace line is the FIRST identity assertion, not the terminal one. tests/Paramore.Brighter.AWS.Tests/MessagingGateway/SnsStandard/Generated/Proactor/When_nacking_a_message_it_should_be_redelivered.cs: :117-118 sends → :121 receivedForNack → :124 NackAsync → :129-136 bounded 30 s/500 ms loop, break on first non-MT_NONE → :139 _messageAssertion.Assert(nackedMessage, redelivered) ← the reported line → :159 terminal assert. AwsMessageAssertion.cs:49 is the fifth comparison, reached only after MessageType/ContentType/CorrelationId/DataSchema all matched — identical between the two messages, so the id is what first discriminates.
Interleaving analysis (FR-16) — which interleavings produce the observed failure:
| # | receive1 | after Nack | result |
|---|---|---|---|
| A | nacked |
following |
:139 fails, expected=nacked actual=following ✅ matches CI |
| B | nacked |
nacked |
passes (the intended path) |
| C | following |
nacked |
:139 passes (expected==actual==nacked); Ack deletes nacked; loop gets following → :159 passes. Self-corrects. |
| D | following |
following |
:139 fails, expected=nacked actual=following ✅ also matches CI |
The CI failure is A or D — both require SQS to have handed the arm the other message. B and C pass, which is exactly why it is intermittent.
FR-7 mechanism. RejectAsync with neither channel → SqsMessageConsumer.cs:263-272 → AcknowledgeAsync → :125-132 → DeleteSourceMessageAsync(receiptHandle) → return true. A wrong-order first receive deletes followingMessage, leaving only rejectedMessage → terminal assert fails with the sibling's id. The ordering assumption is upstream of the failing assertion.
Why FIFO passes. FifoMessageBuilder.cs:43-46 stamps one PartitionKey(Uuid.NewAsString()) per instance; each generated class holds one _messageBuilder, so both messages share a MessageGroupId → strictly ordered and head-of-line blocked. test-configuration.json:20,43 — only SnsFifo/SqsFifo set "MessageBuilder": "FifoMessageBuilder".
Batch size. SqsMessageConsumer.cs:190 MaxNumberOfMessages = _batchSize; providers never pass bufferSize; Subscription.cs:200,286 defaults it to 1. One message per receive — no client-side buffering can reorder or hide anything.
The nack template's third send is irrelevant. The file holds two [Fact]s — a one-message arm and the two-message arm (hence DISTINCTLY_BUILT_MESSAGES = 3 in the golden test). xUnit builds a fresh instance per fact and the provider mints a new topic+queue per call, so they cannot see each other's messages.
(a) At-least-once duplication instead of reordering — RULED OUT for FR-16; NOT ruled out for FR-7. For FR-16 the failure is at :139 with actual = followingMessage.Id; a duplicate of nackedMessage carries the nacked id and would make :139 pass. Reordering is necessarily present. For FR-7 the two are indistinguishable from the log — a surviving duplicate of rejectedMessage produces the same terminal failure.
(b) The transport rewriting Header.MessageId — RULED OUT three ways. SqsMessageCreator.cs:367-376 reads the id from the id attribute verbatim, Id.Random() only as a fallback when absent. (1) Both reported ids decode to the same millisecond — 0x01a0af598f26 = 2026-09-17T12:31:17.286Z — i.e. send time, seconds before any receive, so "actual" is a sibling built in Arrange, not a fabrication at receive. (2) ReadHandledCount (:323-332) defaults to 0 and derives nothing from ApproximateReceiveCount. (3) The single-message nack arm asserts the redelivered message's full identity and is GREEN in the same job — a live proof that a nack round trip preserves the id exactly.
(c) Cross-test pollution — RULED OUT. SnsStandardMessageGatewayProvider.cs:58-66: GetOrCreateRoutingKey/GetOrCreateChannelName accept [CallerMemberName] and ignore it, returning a fresh Uuid.New():N every call. No two tests — and no two facts in one class — can share a topic or queue. (Belt and braces: every gateway has a CollectionName, so the arms are serialised.) The "same millisecond" argument stands on its own and is not load-bearing.
(d) A different assertion position than claimed — RULED OUT. Line 139 is the first identity assertion, as stated. NackAsync is ChangeMessageVisibility(..., 0) (SqsMessageConsumer.cs:334-352), so the nacked message is immediately visible and competes with followingMessage on equal terms. nackedMessage" assumption is not on its own sufficient to fail the nack arm — per row C it self-corrects; it only bites combined with the post-Nack receive, or in FR-7 where the reject destroys the evidence.
Cheaply reproducible gate on any fix (generator tests, no infrastructure): the four golden facts listed in Scope Notes go red the moment the assertion expressions change.
⛔ The fix is wider than the triage implied. In order:
-
Spec first — AC-7 (
requirements.md:634-636), AC-17 (:698-701) and FR-7's Example (:183-184) mandate the order and must be amended before any template moves. Not mine to decide. NFR-2 (:514-522) says the bounded loop returns "as soon as a message arrives"; a drain-until-target loop returns as soon as the target arrives, so it needs a one-clause amendment too, or the rewritten arms read as violating it. -
⭐ Four ledger cells are now falsified — highest-value finding.
conformance-status.md:819,821,823,825record FR-7 =Passand FR-16 =PassforAWS / SnsStandard,AWS / SqsStandard,AWS.V4 / SnsStandard,AWS.V4 / SqsStandard. Those Passes were earned by the pre-2e650c36eblindness and are contradicted by run 35220513183. The ledger drivesSkipemission, so it must be reconciled in the same change orLedgerSkipCrossCheckAuditwill disagree with reality. -
Latent, not yet firing —
GCP / PullFR-7 and FR-16 arePassby the same luck (conformance-status.md:827). Verified unordered:GcpPullMessageGatewayProvider.cs:125-131sets noEnableMessageOrdering, andtests/Paramore.Brighter.Gcp.Tests/test-configuration.json:4-13givesPullnoMessageBuilder, whereasPullOrdering(:19) andStreamOrdering(:42) setFifoMessageBuilderandGcpPullOrderingMessageGatewayProvider.cs:139setsEnableMessageOrdering = true.GCP / StreamisDeferredthroughout so is not exposed.RocketMQ(:839) records FR-7/FR-16Fixed (#4240)on a non-ordered topic — same class. MQTT (:841) is lowest risk (QoS-1, one topic, one client is ordered in practice). -
Regeneration: 96 generated
.csfiles — 48 copies of each arm undertests/**/Generated/(24 wired configurations × Reactor + Proactor) — plus the 4.liquidtemplates. -
Four golden tests pin the current assertion text and must move in the same commit:
CanonicalTemplates/When_generating_nack_test_should_emit_redelivery_and_two_message_variant_both_variants.cs:262,285and.../When_generating_no_channels_reject_should_emit_ack_and_continue_both_variants.cs:214,237— allAssert.Contains("_messageAssertion.Assert(followingMessage, receivedFollowing)", content). The occurrence counts at:257-258,280-281(DISTINCTLY_BUILT_MESSAGES = 3) and:209-210,232-233(= 2) stay green provided each build site keeps its ownSetMessageId(Id.Random())/.SetBody(. -
No
ConformanceAuditis affected by the assertion text — nothing undertests/Paramore.Brighter.Test.Generator.Tests/ConformanceAudit/referencesfollowingMessage,receivedFollowingorStopwatch. OnlyLedgerSkipCrossCheckAuditbecomes relevant, and only if ledger cells change (they must — item 2). -
⭐ Prior art for the FR-7 remedy, hand-written and NOT regenerated:
tests/Paramore.Brighter.AWS.Tests/MessagingGateway/Sqs/Standard/Reactor/When_rejecting_message_with_no_channels_configured_should_acknowledge_and_log.cs:85-93sends one message and asserts the next receive isMT_NONE— order-free, and a stronger proof of "removed, not redelivered". (Two sibling legacy files exist: theAWS.V4twin and a Kafka one.) -
⚠️ Do not copy themultiple_messagesidiom wholesale..../Reactor/When_a_message_consumer_reads_multiple_messages_should_receive_all_messages.cs.liquid:74is order-free but not duplicate-safe (a duplicate matches and passes while a third message never arrives). Safe for FR-7's identification step, unsafe for FR-16 where redelivery is the whole point.
Diagnosis approved by the user. Scope accepted as widened: spec amendment + 4 falsified ledger
cells + GCP / Pull latent.
All five edits are in specs/0036-universal-transport-conformance-tests/requirements.md
(+52/-7): AC-7, AC-17, FR-7's Example, NFR-2's target clause, and the new NFR-4 (Ordering
neutrality). /bugfix:test writes against this text, not the pre-amendment text.
⭐ The user added a rule on approval that is now part of NFR-4 and is broader than the bug:
a scheduler-mediated delay de-orders even on a transport that guarantees ordering. The harness
scheduler re-publishes the message when the delay elapses (ConformanceHarnessMessageScheduler.cs
header comment, 24 copies tree-wide), and a re-publish is a new enqueue — it lands at the tail of
the queue, partition or FIFO group, behind anything sent in the interim. So FR-2 and FR-9 arms may
never assume a delayed message keeps its position, on any configuration. This is a property of
the delay mechanism, not of a transport.
AWS{,.V4} / Sns|SqsStandard none; GCP / Pull+Stream none;
GCP / PullOrdering+StreamOrdering per key; AWS{,.V4} / Sns|SqsFifo per group but
harness-induced). Kafka, RMQ ×3, Redis, Postgres, MSSQL, ASB, RocketMQ and MQTT are not
verified and must not be recorded as ordered until they are.
⛔ Superseded — kept for the record. The original blocking note said: The user chose to amend the ACs themselves, first, as spec owner — so that the obligation under test is not authored by the same agent that then tests it. The regression test is written against the amended AC text, not the current text.
Waiting on edits to specs/0036-universal-transport-conformance-tests/requirements.md:
| What | Where | Why |
|---|---|---|
| AC-7 | :634-636 |
"the next receive yields M2" mandates order |
| AC-17 | :698-701 |
"the redelivered M is received and then M2" mandates order |
| FR-7 Example | :183-184 |
re-imports the order the FR prose leaves out |
| NFR-2 | :514-522 |
"returns as soon as a message arrives" — a drain-until-target loop returns as soon as the target arrives |
| ⭐ New | — | a statement of which wired configurations guarantee ordering. This omission is the root defect: it is what let an order-dependent AC be written for a suite that targets unordered transports. |
2e650c36e exists to catch. "Both messages were eventually received" is satisfied by an
implementation that never redelivers at all — the nacked message's first arrival plus the
following message's arrival already makes two ids. The assertion has to key on seeing an id again
after the Nack, which is a redelivery by definition. And no "exactly once" counting assertion:
Standard is at-least-once and it would trade an ordering flake for a duplicate flake.
⭐ Draft amendments written for review: ac-amendments-draft.md in this folder (5 edits — AC-7, AC-17, FR-7 Example, NFR-2, and a new NFR-4 Ordering neutrality).
tests/Paramore.Brighter.Test.Generator.Tests/CanonicalTemplates/When_generating_two_message_arms_should_identify_received_messages_by_id.cs
— class TwoMessageArmsIdentifyReceivedMessagesByIdTests, 4 facts (nack + no-channels, each ×
Reactor/Proactor).
⭐ Pinned at the GENERATOR level, not the transport level. The confirmed cause is in the
templates; the 96 generated copies are its output. So the regression is caught broker-free in the
build job in ~20 ms instead of on a live AWS run — which is the only reason it was catchable at
all, given the defect needs real multi-node SQS to manifest.
Each fact asserts three things:
| # | Assertion | Role |
|---|---|---|
| 1 | Header.MessageId appears — the arm identifies receipts by id |
The red. 0 occurrences in either template today |
| 2 | _messageAssertion.Assert(nackedMessage, redelivered) and …(followingMessage, receivedFollowing) are absent |
The defect itself |
| 3 | _messageAssertion.Assert(message, redelivered) survives |
⭐ RED measured: 4 failed / 271 passed / 275 total — the only failures are the four new facts.
⛔ Both halves were canary-proved, not argued. All four fail on assertion 1, which means the
DoesNotContain halves are never reached and would otherwise be unproven. Probed by temporarily
pointing IDENTIFIES_BY_ID at a string that is present: both fired, naming nackedMessage, r…
and followingMessage… at real offsets. Canary reverted.
…nack_test…:262,285
and …no_channels_reject…:214,237, all Assert.Contains("_messageAssertion.Assert(followingMessage, receivedFollowing)")). That conflict is the point — it forces both to move in the same commit. The
fix will turn those four red and they must be rewritten then.
The arms now resolve identity by id and assert over the set of ids observed, never over arrival
position. Four .liquid templates under
tools/Paramore.Brighter.Test.Generator/Templates/MessagingGateway/{Reactor,Proactor}/:
When_nacking_a_message_it_should_be_redelivered and
When_rejecting_message_with_no_channels_configured_should_acknowledge_and_log.
| Arm | Shape now |
|---|---|
| FR-16 | Receive → _sentMessages.Single(m => m.Header.MessageId == receivedForNack.Header.MessageId) names the nacked one, its sibling is theOtherMessage. Nack. One bounded loop accumulates Header.MessageId.Value into a HashSet<string>, acknowledging each receipt, exiting at Count < 2 or the 30 s ceiling. Asserts both ids present. |
| FR-7 | Receive → same id lookup names rejectedMessage and theOtherMessage. Assert.True(rejected) unchanged. Bounded loop takes the first non-MT_NONE receipt, asserting NotEqual(rejectedMessage.Header.MessageId, next.Header.MessageId) — the rejected message coming back is still forbidden — then _messageAssertion.Assert(theOtherMessage, receivedOther). |
⭐ Why FR-16 proves redelivery without naming a position: the nacked message was already
received once before the Nack, so its id appearing again inside the post-Nack loop is a
redelivery by definition. An implementation that never redelivers never produces that id in that
window and times out. ⛔ Do not "simplify" this to "both messages were received" — that is
satisfied by a gateway that never redelivers, and is precisely the blindness 2e650c36e removed.
⭐ The acknowledge-as-you-go clause is load-bearing, not housekeeping. Without it a transport that blocks a message group while one of its messages is in flight (FIFO) can never deliver the second id, and the arm would fail the transports that are most correct. This is AC-17's clause, which the regression test does not pin — it is honoured here deliberately.
Supporting changes, all required by the same cause:
using System.Linq;added to all four templates (forSingle).- Build-site variables renamed
nackedMessage/rejectedMessage+followingMessage→firstSent/secondSent.⚠️ Not cosmetic:followingMessageasserts an ordering claim in its own name. The names that carry meaning are now the derived ones, resolved by id. - The single-message nack arm is untouched — one message in flight raises no ordering question,
so
_messageAssertion.Assert(message, redelivered)stays correct. Pinned by the regression test. - Four golden assertions updated in the same commit as predicted by the Scope Notes
(
…nack_test…×2,…no_channels_reject…×2): they asserted the now-deleted positional expressions and now assert the id-based ones. TheirDISTINCTLY_BUILT_MESSAGEScounts (3 and 2) were unaffected — each build site kept its ownSetMessageId(Id.Random())/.SetBody(.
⭐ Measured, not predicted:
| Regression test | 4/4 green (was 4/4 red) |
| Generator suite | 275 / 275, 0 failed |
./generate-test.sh |
exit 0 — exactly 96 generated files changed, and nothing else: 48 × each arm across 24 wired configurations × Reactor + Proactor, precisely the Scope Notes' count |
dotnet build Brighter.slnx |
0 errors |
✅ PROVEN ON A REAL BROKER — aws-ci green on f7ad18d6f (run 35275879441). ⭐ Checked by
name, not taken from the green tick: every arm RAN — 0 Skipped, 0 Failed across 48 identities
(2 SDK majors × 4 configurations × Reactor/Proactor × 3 facts), each seen twice because the job
runs the suite twice, so both attempts are green and the non-determinism is gone.
⭐⭐ This is the first evidence that FR-7 and FR-16 hold on an UNORDERED transport. No test could
tell before: the two messages were first indistinguishable, then distinguishable but order-dependent.
The four AWS{,.V4} / Sns|SqsStandard cells are therefore earned, not inherited — they stay
Pass and need no ledger change. The single-message nack arm still passes everywhere, so the scope
guard held.