feat: expand MQTT contract and reliability lessons

This commit is contained in:
2026-10-13 18:08:17 +03:30
parent 2d639d78eb
commit f9ab91ed60
17 changed files with 2083 additions and 91 deletions
+471 -14
View File
@@ -2,7 +2,7 @@
## 🎬 Start with the system story
> 💡 **Story question:** The broker acknowledged a backend's command publication. How can the backend match a later device response to the right request—and what should it report if no response arrives?
> **❓ Question:** **The broker acknowledged a backend's command publication. How can the backend match a later device response to the right request—and what should it report if no response arrives?**
The MQTT acknowledgement covers delivery on one hop. The device's application response is a separate publication. The backend therefore needs application-level command identity, correlation, a pending state registered before publication, and a timeout policy that does not claim the device definitely did nothing.
@@ -28,7 +28,7 @@ sequenceDiagram
Prerequisite: Sections 5–6 validate messages and bound concurrent processing. The backend and simulator continue to read their shared disposable password from `configs/lab.toml`; select each broker username with `-username` when starting the process.
Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Create files at the project-relative paths shown below, not under `lessons/`. Continue the same application and pinned Paho v0.23.0 dependency. These are guided implementation exercises; attempt each step before requesting a full solution.
Run all commands from `/home/ryan/Media/Code/Code/robot-market/mqtt-5-learning`. Save the complete implementation blocks at the project-relative paths shown below. This lesson provides the command-state and simulator-deduplication code; the exercises ask you to trace its evidence and failure behavior.
## ↩️ MQTT request/response is an application conversation
@@ -37,6 +37,7 @@ Response Topic tells the responder where a result should be published. Correlati
This fragment is the packet-construction portion of a service method, not a standalone program. `commandJSON` is an encoded validated command and `correlation` is owned by its pending record:
```go
// MQTT 5 carries the reply destination and request identity in packet properties.
request := &paho.Publish{
Topic: "lab/v1/devices/device-001/commands",
QoS: 1,
@@ -52,7 +53,11 @@ request := &paho.Publish{
// Send this packet only after the pending request has been registered.
```
Pass `request` to the bounded publication operation after registering pending state. The simulator validates `ResponseTopic`, publishes the result to it, and copies Correlation Data unchanged into the result's properties. Correlation bytes are opaque; they need not be text. Do not trust an arbitrary supplied topic as a response destination.
Pass `request` to the bounded publication operation after registering pending state. The simulator reads `ResponseTopic` to find where to publish its result and copies Correlation Data unchanged into the result's properties. Correlation bytes are opaque; they need not be text.
**❓ Question:** **A command for device-001 asks the simulator to reply on `lab/v1/devices/device-002/responses`. What could go wrong if it publishes there?**
**💬 Answer:** The reply could appear in device-002's message stream. The simulator checks that `ResponseTopic` exactly matches its permitted topic, `lab/v1/devices/device-001/responses`. If it does not match, the simulator rejects the command and publishes no reply. Correlation Data matches a result to a request; it does not authorize the destination.
### ↩️ Trace a fast response and a lost response
@@ -77,25 +82,322 @@ Use a synthetic effect counter. Send command C twice with the same content, then
Concurrent duplicates need atomic admission: a lookup followed by an unlocked effect allows two workers to both observe "not seen". Reserve the ID with its fingerprint before executing, and define what a second request sees while the first is in progress. A real effect followed by a crash before storing its result remains a separate failure window; the in-memory simulator only demonstrates the idea.
<details>
<summary>The broker acknowledged, but no device result arrived. Which status is justified?</summary>
<summary><strong>❓ Question: The broker acknowledged, but no device result arrived. Which status is justified?</strong></summary>
Only the transport acknowledgement is established. The device may be offline, may have rejected the request, may still be executing, or may have executed with a lost response. Once its deadline expires, expose unknown outcome. Retry only under the same business identity and a deliberate idempotency policy.
**💬 Answer:** Only the transport acknowledgement is established. The device may be offline, may have rejected the request, may still be executing, or may have executed with a lost response. Once its deadline expires, expose unknown outcome. Retry only under the same business identity and a deliberate idempotency policy.
</details>
## 💾 1. Preserve MQTT 5 request metadata
> 💡 **Story question:** How can a responder know where to send a reply and the requester know which command the reply belongs to?
> **❓ Question:** **How can a responder know where to send a reply and the requester know which command the reply belongs to?**
Use the MQTT 5 Response Topic and Correlation Data as transport metadata, while retaining a stable application command ID and checking that the response destination is authorized.
Create `internal/commands/service.go`, `store.go`, and `service_test.go`. Edit `internal/mqtt/client.go` to carry Response Topic and a copied Correlation Data slice in received messages. Add an outbound request form that accepts these properties. Retain the existing bounded publish timeout and reason-code checks.
Save the following complete files as `internal/commands/store.go` and `service.go`. The service stores pending state before publishing, keeps broker acknowledgement separate from device status, and matches a response by device, command ID, and Correlation Data.
`internal/commands/store.go`:
```go
package commands
import (
"fmt"
"bytes"
"sync"
"time"
"example.com/mqtt5-learning/internal/contracts"
)
type Status string
// Status records application progress independently from BrokerAcked.
const (
Pending Status = "pending"
Accepted Status = "accepted"
Completed Status = "completed"
Rejected Status = "rejected"
Unknown Status = "unknown"
)
type Record struct {
CommandID string
DeviceID string
CorrelationData []byte
CreatedAt time.Time
Deadline time.Time
Status Status
BrokerAcked bool
AckReasonCode byte
Detail string
}
type Store struct {
mu sync.RWMutex
records map[string]Record
}
// NewStore creates the in-memory command record table.
func NewStore() *Store { return &Store{records: make(map[string]Record)} }
func key(deviceID, commandID string) string { return deviceID + "/" + commandID }
func (s *Store) Insert(record Record) error {
s.mu.Lock()
defer s.mu.Unlock()
k := key(record.DeviceID, record.CommandID)
if _, exists := s.records[k]; exists { return fmt.Errorf("command already exists") }
record.CorrelationData = append([]byte(nil), record.CorrelationData...)
s.records[k] = record
return nil
}
// Get and Update copy correlation bytes so callers cannot mutate stored state.
func (s *Store) Get(deviceID, commandID string) (Record, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
record, ok := s.records[key(deviceID, commandID)]
record.CorrelationData = append([]byte(nil), record.CorrelationData...)
return record, ok
}
func (s *Store) Update(record Record) error {
s.mu.Lock()
defer s.mu.Unlock()
k := key(record.DeviceID, record.CommandID)
if _, exists := s.records[k]; !exists { return fmt.Errorf("command not found") }
record.CorrelationData = append([]byte(nil), record.CorrelationData...)
s.records[k] = record
return nil
}
func (s *Store) MarkBrokerAck(deviceID, commandID string, reason byte) (Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
k := key(deviceID, commandID)
record, ok := s.records[k]
if !ok { return Record{}, fmt.Errorf("command not found") }
record.BrokerAcked, record.AckReasonCode = true, reason
s.records[k] = record
return record, nil
}
// MarkUnknownIfPending preserves any application result that arrived first.
func (s *Store) MarkUnknownIfPending(deviceID, commandID, detail string) (Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
k := key(deviceID, commandID)
record, ok := s.records[k]
if !ok { return Record{}, fmt.Errorf("command not found") }
if record.Status == Pending || record.Status == Accepted { record.Status, record.Detail = Unknown, detail }
s.records[k] = record
return record, nil
}
// ApplyResult checks both device scope and correlation before changing status.
func (s *Store) ApplyResult(deviceID string, correlation []byte, result contracts.CommandResult) (Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
k := key(deviceID, result.CommandID)
record, ok := s.records[k]
if !ok { return Record{}, fmt.Errorf("no pending command matches result") }
if result.DeviceID != record.DeviceID || !bytes.Equal(correlation, record.CorrelationData) {
return Record{}, fmt.Errorf("result identity or correlation does not match pending command")
}
if record.Status == Completed || record.Status == Rejected { return record, nil }
switch result.Status {
case "accepted": record.Status = Accepted
case "completed": record.Status = Completed
case "rejected": record.Status = Rejected
default: return Record{}, fmt.Errorf("unsupported result status %q", result.Status)
}
record.Detail = result.Detail
s.records[k] = record
return record, nil
}
func (s *Store) List() []Record {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Record, 0, len(s.records))
for _, record := range s.records {
record.CorrelationData = append([]byte(nil), record.CorrelationData...)
result = append(result, record)
}
return result
}
```
`internal/commands/service.go`:
```go
package commands
import (
"context"
"crypto/rand"
"fmt"
"time"
"example.com/mqtt5-learning/internal/contracts"
)
type Publisher interface {
PublishCommand(context.Context, string, contracts.Command, string, []byte) (byte, error)
}
// Service coordinates command identity, storage, publication, and deadlines.
type Service struct {
store *Store
publisher Publisher
now func() time.Time
timeout time.Duration
}
func NewService(store *Store, publisher Publisher, timeout time.Duration) *Service {
return &Service{store: store, publisher: publisher, now: func() time.Time { return time.Now().UTC() }, timeout: timeout}
}
func (s *Service) CreateAndPublish(ctx context.Context, deviceID, commandID string) (Record, error) {
// Store pending state first so a fast device response can match immediately.
now := s.now()
correlation := make([]byte, 16)
if _, err := rand.Read(correlation); err != nil { return Record{}, fmt.Errorf("create correlation data: %w", err) }
record := Record{CommandID: commandID, DeviceID: deviceID, CorrelationData: correlation, CreatedAt: now, Deadline: now.Add(s.timeout), Status: Pending}
if err := s.store.Insert(record); err != nil { return Record{}, err }
command := contracts.Command{SchemaVersion: contracts.SchemaVersion, MessageID: "message-"+commandID, DeviceID: deviceID, OccurredAt: now.Format(time.RFC3339), CommandID: commandID, Command: "ping"}
responseTopic := "lab/v1/devices/"+deviceID+"/responses"
ctx, cancel := context.WithDeadline(ctx, record.Deadline)
defer cancel()
reason, err := s.publisher.PublishCommand(ctx, deviceID, command, responseTopic, append([]byte(nil), correlation...))
if err != nil {
updated, _ := s.store.MarkUnknownIfPending(deviceID, commandID, "publish outcome uncertain: "+err.Error())
return updated, err
}
updated, err := s.store.MarkBrokerAck(deviceID, commandID, reason)
return updated, err
}
func (s *Service) HandleResult(deviceID string, correlation []byte, result contracts.CommandResult) (Record, error) {
return s.store.ApplyResult(deviceID, correlation, result)
}
// Expire records uncertainty; it does not claim that the device did no work.
func (s *Service) Expire(now time.Time) {
for _, record := range s.store.List() {
if (record.Status == Pending || record.Status == Accepted) && !now.Before(record.Deadline) {
_, _ = s.store.MarkUnknownIfPending(record.DeviceID, record.CommandID, "response deadline expired; execution outcome is unknown")
}
}
}
func (s *Service) Get(commandID string) (Record, bool) {
for _, record := range s.store.List() {
if record.CommandID == commandID { return record, true }
}
return Record{}, false
}
```
Read `references/paho.golang-v0.23.0/paho/cp_publish.go` for `paho.PublishProperties`. Set `ResponseTopic` to `lab/v1/devices/device-001/responses` and `CorrelationData` to opaque bytes associated with the pending command. Establish the backend response subscription before sending. Register pending state before publishing so an immediate response cannot beat registration.
Save this complete adapter as `internal/mqtt/command_publisher.go`. It checks the response destination before publishing, copies Correlation Data, uses QoS 1, and returns the broker reason code separately from the device result.
```go
package mqtt
import (
"context"
"encoding/json"
"fmt"
"time"
"example.com/mqtt5-learning/internal/contracts"
"github.com/eclipse/paho.golang/autopaho"
"github.com/eclipse/paho.golang/paho"
)
type CommandPublisher struct {
Manager *autopaho.ConnectionManager
}
func (p CommandPublisher) PublishCommand(ctx context.Context, deviceID string, command contracts.Command, responseTopic string, correlation []byte) (byte, error) {
// Authorize the reply destination before putting it in the MQTT packet.
authorized := "lab/v1/devices/" + deviceID + "/responses"
if responseTopic != authorized { return 0, fmt.Errorf("unauthorized response topic") }
if command.DeviceID != deviceID { return 0, fmt.Errorf("command device does not match publish destination") }
payload, err := json.Marshal(command)
if err != nil { return 0, fmt.Errorf("encode command: %w", err) }
operation, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
// Correlation Data is copied so the packet owns stable bytes for this publish.
ack, err := p.Manager.Publish(operation, &paho.Publish{
Topic: "lab/v1/devices/" + deviceID + "/commands",
QoS: 1,
Payload: payload,
Properties: &paho.PublishProperties{
ContentType: "application/json",
ResponseTopic: responseTopic,
CorrelationData: append([]byte(nil), correlation...),
},
})
if err != nil { return 0, fmt.Errorf("publish command: %w", err) }
if ack == nil { return 0, fmt.Errorf("missing QoS 1 PUBACK") }
if ack.ReasonCode >= 0x80 { return ack.ReasonCode, fmt.Errorf("broker rejected publish: reason 0x%02x", ack.ReasonCode) }
return ack.ReasonCode, nil
}
```
The receive callback must copy packet properties while the packet is available. Add these fields to the existing `mqtt.Message` and set them in its receive callback:
```go
type Message struct {
Topic string
Payload []byte
Retain bool
ResponseTopic string
CorrelationData []byte
}
// Copy properties alongside payload before the receive packet leaves the callback.
properties := packet.Properties
message := Message{Topic: packet.Topic, Payload: append([]byte(nil), packet.Payload...), Retain: packet.Retain}
if properties != nil {
message.ResponseTopic = properties.ResponseTopic
message.CorrelationData = append([]byte(nil), properties.CorrelationData...)
}
```
Save this complete result receiver as `internal/commands/receive.go`. It validates the MQTT topic and payload before applying a response, then matches the received Correlation Data against the pending command.
```go
package commands
import (
"fmt"
"time"
"example.com/mqtt5-learning/internal/contracts"
"example.com/mqtt5-learning/internal/mqtt"
)
func HandleMessage(service *Service, message mqtt.Message) error {
// Validate the route and result body before matching the pending request.
topic, err := contracts.ParseTopic(message.Topic)
if err != nil { return err }
if topic.Kind != contracts.KindResponses { return fmt.Errorf("expected a response topic") }
result, err := contracts.Decode[contracts.CommandResult](message.Payload, topic, time.Now().UTC())
if err != nil { return err }
_, err = service.HandleResult(topic.DeviceID, append([]byte(nil), message.CorrelationData...), result)
return err
}
```
## 🧩 2. Model evidence explicitly
> 💡 **Story question:** What should the backend show after a PUBACK but before any device response arrives?
> **❓ Question:** **What should the backend show after a PUBACK but before any device response arrives?**
Represent broker delivery evidence separately from device acceptance, completion, rejection, and timeout/unknown outcome.
@@ -105,21 +407,176 @@ Protect concurrent access and define allowed transitions. A completed or rejecte
## ♻️ 3. Make the simulator idempotent
> 💡 **Story question:** If QoS 1 or an application retry delivers the same command again, how does the device avoid repeating a non-idempotent effect?
> **❓ Question:** **If QoS 1 or an application retry delivers the same command again, how does the device avoid repeating a non-idempotent effect?**
Record results by stable command ID and replay the prior result for duplicates instead of applying the simulated effect twice.
Create `internal/simulator/dedup.go` and its tests. Index effects by stable command ID and retain a fingerprint of the command. For an identical duplicate, replay the recorded result without repeating the effect. Reject reuse of the ID with different content. Bound cache lifetime and size; state clearly that restarting the simulator loses this in-memory protection.
Save this complete in-memory simulator cache as `internal/simulator/dedup.go`. It serializes admission and the synthetic effect under one lock, replays an identical result, rejects reuse of an ID with different content, and refuses new work when full rather than evicting live deduplication state.
Allow a Response Topic only when it exactly matches the authorized response topic for this simulator. Correlation is matching data, not authorization. Continue rejecting retained one-shot commands.
```go
package simulator
import (
"crypto/sha256"
"encoding/json"
"fmt"
"sync"
"example.com/mqtt5-learning/internal/contracts"
)
type cachedResult struct {
fingerprint [32]byte
result contracts.CommandResult
}
type Deduplicator struct {
mu sync.Mutex
capacity int
results map[string]cachedResult
}
// NewDeduplicator bounds how many command results this simulator retains.
func NewDeduplicator(capacity int) *Deduplicator {
if capacity < 1 { capacity = 1 }
return &Deduplicator{capacity: capacity, results: make(map[string]cachedResult)}
}
func AuthorizedResponseTopic(deviceID, requested string) bool {
return requested == "lab/v1/devices/"+deviceID+"/responses"
}
// Apply executes effect at most once for a device/command ID while this process lives.
func (d *Deduplicator) Apply(command contracts.Command, effect func() (contracts.CommandResult, error)) (contracts.CommandResult, bool, error) {
if effect == nil { return contracts.CommandResult{}, false, fmt.Errorf("effect is required") }
encoded, err := json.Marshal(command)
if err != nil { return contracts.CommandResult{}, false, fmt.Errorf("fingerprint command: %w", err) }
fingerprint := sha256.Sum256(encoded)
key := command.DeviceID + "/" + command.CommandID
d.mu.Lock()
defer d.mu.Unlock()
if previous, ok := d.results[key]; ok {
if previous.fingerprint != fingerprint { return contracts.CommandResult{}, false, fmt.Errorf("command ID was reused with different content") }
return previous.result, true, nil
}
if len(d.results) >= d.capacity { return contracts.CommandResult{}, false, fmt.Errorf("deduplication cache is full") }
result, err := effect()
if err != nil { return contracts.CommandResult{}, false, err }
d.results[key] = cachedResult{fingerprint: fingerprint, result: result}
return result, false, nil
}
```
Use the deduplicator in the complete simulator handler below. Save it as `internal/simulator/respond.go`; it checks the requested destination before applying the effect, then publishes the result with the original Correlation Data.
```go
package simulator
import (
"context"
"encoding/json"
"fmt"
"time"
"example.com/mqtt5-learning/internal/contracts"
"example.com/mqtt5-learning/internal/mqtt"
"github.com/eclipse/paho.golang/autopaho"
"github.com/eclipse/paho.golang/paho"
)
func Respond(ctx context.Context, manager *autopaho.ConnectionManager, cache *Deduplicator, deviceID string, message mqtt.Message) error {
// Reject retained or misrouted requests before validating or applying an effect.
if message.Retain { return fmt.Errorf("retained commands are rejected") }
topic, err := contracts.ParseTopic(message.Topic)
if err != nil { return err }
if topic.Kind != contracts.KindCommands || topic.DeviceID != deviceID { return fmt.Errorf("command topic is not authorized for this device") }
command, err := contracts.Decode[contracts.Command](message.Payload, topic, time.Now().UTC())
if err != nil { return err }
if !AuthorizedResponseTopic(deviceID, message.ResponseTopic) { return fmt.Errorf("unauthorized response topic") }
// Cache the simulated result by stable command ID to suppress repeat effects.
result, _, err := cache.Apply(command, func() (contracts.CommandResult, error) {
return contracts.CommandResult{SchemaVersion: contracts.SchemaVersion, MessageID: "result-"+command.CommandID, DeviceID: deviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: command.CommandID, Status: "completed"}, nil
})
if err != nil { return err }
payload, err := json.Marshal(result)
if err != nil { return fmt.Errorf("encode result: %w", err) }
operation, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
// Reply only to the authorized destination and echo correlation bytes unchanged.
ack, err := manager.Publish(operation, &paho.Publish{Topic: message.ResponseTopic, QoS: 1, Payload: payload, Properties: &paho.PublishProperties{ContentType: "application/json", CorrelationData: append([]byte(nil), message.CorrelationData...)}})
if err != nil { return fmt.Errorf("publish response: %w", err) }
if ack == nil { return fmt.Errorf("missing response PUBACK") }
if ack.ReasonCode >= 0x80 { return fmt.Errorf("response publish rejected: reason 0x%02x", ack.ReasonCode) }
return nil
}
```
Replace the Section 5 simulator entrypoint with this complete version to use the deduplication and response handler. It retains the validated heartbeat publisher and routes every received command through `Respond`.
```go
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"example.com/mqtt5-learning/internal/config"
"example.com/mqtt5-learning/internal/contracts"
"example.com/mqtt5-learning/internal/mqtt"
"example.com/mqtt5-learning/internal/simulator"
)
func main() {
if err := run(); err != nil { log.Fatal(err) }
}
func run() error {
cfg, err := config.Load(os.Args[1:])
if err != nil { return fmt.Errorf("load config: %w", err) }
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
base := "lab/v1/devices/" + cfg.MQTT.DeviceID
inbox := make(chan mqtt.Message, 16)
manager, err := mqtt.Start(ctx, cfg, []string{base+"/commands"}, inbox)
if err != nil { return fmt.Errorf("start MQTT: %w", err) }
defer mqtt.Stop(manager)
// Keep recent command results so repeated delivery does not repeat the effect.
cache := simulator.NewDeduplicator(1024)
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
sequence := 0
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
sequence++
heartbeat := contracts.Heartbeat{SchemaVersion: contracts.SchemaVersion, MessageID: fmt.Sprintf("heartbeat-%d-%d", time.Now().Unix(), sequence), DeviceID: cfg.MQTT.DeviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339)}
payload, err := json.Marshal(heartbeat)
if err != nil { return fmt.Errorf("encode heartbeat: %w", err) }
if err := mqtt.Publish(ctx, manager, base+"/telemetry", payload); err != nil { log.Printf("heartbeat publish: %v", err) }
case message := <-inbox:
if err := simulator.Respond(ctx, manager, cache, cfg.MQTT.DeviceID, message); err != nil { log.Printf("command rejected or response failed topic=%q reason=%v", message.Topic, err) }
}
}
}
```
The device checks that the requested response destination exactly matches its own response topic before publishing. Correlation Data matches the result to the pending request; it does not authorize a destination. Retained one-shot commands are rejected before parsing.
## 🧪 4. Exercise timing and failures
> 💡 **Story question:** If a response is late or missing, what can the backend safely conclude, and what should happen to a later result?
> **❓ Question:** **If a response is late or missing, what can the backend safely conclude, and what should happen to a later result?**
Exercise timeouts and duplicates while keeping uncertainty visible; a timeout is not proof that the device did nothing.
Replace the backend's unconditional ticker with an explicit finite command exercise or service calls. Send 20 concurrent synthetic pings with separate IDs. Introduce delayed, duplicate, wrong-device, wrong-correlation, and out-of-order responses. Check every command receives only its own evidence. Retry a timed-out request with the same stable ID; never generate a new ID merely because publication timed out.
Use the completed command service and simulator handler to trace 20 concurrent pings with separate IDs. Observe delayed, duplicate, wrong-device, wrong-correlation, and out-of-order responses. Check that each command receives only matching evidence. A retry keeps the same stable command ID; a publish timeout alone does not prove that the device received nothing.
```sh
mkdir -p internal/commands internal/simulator
@@ -133,7 +590,7 @@ Run `mkdir -p` before saving the service and simulator package files. `gofmt` fo
When copying Correlation Data, allocate a new byte slice so later processing owns stable bytes. Comparing correlation bytes means comparing their contents, not pointer addresses. Before updating a shared command map, validate the response and hold its lock only for the state transition. Publish outside the lock so a network delay cannot freeze every command.
Test responses arriving before publish returns, publish failure with uncertain outcome, timeout cleanup, deduplication, and terminal-state protection. Use an injectable clock and fake transport for deterministic service tests.
The implementation handles responses that arrive before the publish call returns because pending state is inserted first and broker acknowledgement updates only its own fields. Inspect the matching, timeout, deduplication, and terminal-state branches in the complete files above; a fake transport can isolate each case without a broker.
**Done when:** Concurrent requests match correctly, identical duplicates cause one effect, unexpected destinations are rejected, and late responses follow a documented policy. Explain why PUBACK and Correlation Data cannot prove business completion.