Files
mqtt-5-learning/lessons/07-command-response.md
T

28 KiB
Raw Blame History

🔁 Section 7 — Build reliable command and response behavior

🎬 Start with the system 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?

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.

sequenceDiagram
    participant H as HTTP/backend caller
    participant G as Go command service
    participant B as Broker
    participant D as Device
    H->>G: Request command
    G->>G: Create command ID and pending waiter
    G->>B: PUBLISH command with correlation data
    B-->>G: PUBACK: broker hop accepted
    B->>D: PUBLISH command
    D->>D: Validate, deduplicate, act
    D->>B: PUBLISH correlated application result
    B->>G: PUBLISH result
    G->>G: Match identity/correlation; update state
    G-->>H: Accepted / completed / rejected / unknown

Before each implementation step, ask: Which event changes command state: broker acknowledgement, device acceptance, device completion, or timeout? Keep those events separate. Follow Lesson 0 for the full client and broker lifecycle.

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. 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

Response Topic tells the responder where a result should be published. Correlation Data lets the requester recognize which pending request it belongs to. The broker forwards these properties; it does not run the command, create a result, or enforce your timeout.

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:

// MQTT 5 carries the reply destination and request identity in packet properties.
request := &paho.Publish{
    Topic:   "lab/v1/devices/device-001/commands",
    QoS:     1,
    Retain:  false,
    Payload: commandJSON,
    Properties: &paho.PublishProperties{
        ContentType:     "application/json",
        ResponseTopic:   "lab/v1/devices/device-001/responses",
        CorrelationData: append([]byte(nil), correlation...),
    },
}

// Send this packet only after the pending request has been registered.

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

  1. Backend establishes its response subscription and checks SUBACK.
  2. Backend stores the expected device, command ID, correlation, and deadline.
  3. Backend publishes. The simulator can respond before the publish call returns.
  4. Backend validates and applies device evidence independently of the broker acknowledgement.
  5. If the waiting deadline expires, backend removes the waiter and records unknown outcome. A later result follows the documented late-result policy.

Registering after step 3 loses the race with a fast response. Holding the command-map mutex across publishing can block the callback needed to record that response. A context timeout stops a local wait; it cannot recall bytes already delivered to a device.

Identifier Owner and lifetime Why it cannot replace the others
MQTT packet ID Library, per protocol exchange/session direction, reusable Identifies a transport handshake, not a permanent command
Correlation Data Request/response mechanism Matches a response; does not authenticate it or prevent repeated effects
Command ID Application, stable across retries Keys the dedup record and business status

♻️ Make duplicate behavior observable

Use a synthetic effect counter. Send command C twice with the same content, then C with changed content. Expect one effect, a replayed recorded result, then a conflict. Also send the same C in two separate publish calls: application duplicates need not carry MQTT DUP. The DUP flag describes protocol retransmission, so deduplicating only packets with DUP set is wrong.

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.

❓ Question: The broker acknowledged, but no device result arrived. Which status is justified?

💬 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.

💾 1. Preserve MQTT 5 request metadata

❓ 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.

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:

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:

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.

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:

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.

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

❓ 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.

Store command ID, expected device, correlation bytes, creation/deadline times, business status, and independent transport acknowledgement evidence. Start with created → pending delivery → accepted → completed or rejected. A ping simulator can send accepted followed by completed. Response timeout means unknown outcome; it does not establish that no effect happened.

Protect concurrent access and define allowed transitions. A completed or rejected command must not regress on a duplicate accepted response. Compare command identity, topic/body device identity, and correlation before applying a response. Keep timed-out records for a bounded retention period even after removing their waiters, so status can explain the uncertainty.

♻️ 3. Make the simulator idempotent

❓ 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.

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.

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.

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.

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

❓ 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.

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.

mkdir -p internal/commands internal/simulator

gofmt -w internal cmd

go test -race ./...

Run mkdir -p before saving the service and simulator package files. gofmt formats your edits and go test -race ./... executes the module's tests with concurrency instrumentation. A fake transport is a small in-process implementation of the caller's transport interface: it lets tests deliberately return errors or responses without needing a broker. An injected clock makes deadline tests deterministic without waiting in real time.

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.

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.

✅ Result — the story so far

The backend now treats a ping as a tracked command: it records the command ID, expected device, correlation data, and deadline before publishing. The simulator checks the command and authorized response destination, applies the simulated effect once for that stable ID, and publishes a result. The backend matches that result to the pending command and updates its status. A broker PUBACK still means only that the broker acknowledged its hop; a missing response leaves the outcome unknown, not necessarily unexecuted.

Previous: 06-concurrency-cancellation.md. Next: 08-http-commands.md.