Files
mqtt-5-learning/teaching/course-source/05-contracts-validation.md
T
Ryan fb30e8e884 Refactor MQTT course and bound backend message processing
Move lesson source into the teaching structure, align the course path with the learner's Go and DevOps experience, and add the interactive lesson guides. Add advanced DevOps lifecycle study as a separate section.

Bound MQTT message work and drain accepted jobs during shutdown; align the backend implementation with the revised course through Section 6.
2026-10-22 10:19:56 +03:30

32 KiB

📋 Section 5 — Design contracts and validate inbound messages

🎬 Start with the system story

❓ Question: The broker can route a message because its topic matches. How does the Go backend decide that the topic and payload describe a valid message from the expected device?

💬 Answer: The topic provides the route, but it does not verify the payload. The backend parses the topic, validates the JSON, and compares the claimed device ID before business handling. Broker authentication and ACLs separately control who may publish to a topic.

flowchart LR
    B[Broker routes matching topic] --> P[Paho delivers topic + bytes]
    P --> T[Parse topic and device scope]
    T --> J[Decode versioned JSON]
    J --> V[Validate identity, IDs, size, time]
    V -->|valid| H[Business handler]
    V -->|invalid| E[Safe rejection / metric]

Before each implementation step, ask: What can be trusted at this boundary, and what must be checked before the application acts? Follow Lesson 0 for the complete message path.

Prerequisite: Section 4 exchanges heartbeats and ping/pong. The clients share the disposable password in configs/lab.toml; select the simulator account with -username simulator when starting it.

Run all commands from /home/ryan/Media/Code/Code/robot-market/mqtt-5-learning. Save the complete code blocks at their project-relative paths, not under teaching/course-source/. This lesson provides a full working implementation with the pinned Paho v0.23.0 dependency; use the exercises to inspect and verify its behavior.

📨 Follow one message through the trust boundaries

❓ Question: The backend receives a sales report on device-001's topic, but its JSON says device_id: device-002. What could go wrong if the backend accepts it?

💬 Answer: The report could be applied to the wrong device. The broker may allow the publish, but it does not compare the topic with the JSON. The backend compares both IDs and rejects a mismatch before changing data.

PUBLISH → size limit → topic parser → JSON decoder → schema checks
        → topic/body identity check → business validation → handler

The backend applies several checks because each catches a different problem. A size limit bounds the payload it decodes. The topic parser identifies the device and message type. JSON decoding checks the data's shape, while business validation checks its values. For example, a sales report can be valid JSON but contain a negative quantity. The backend rejects that value. These checks do not replace broker authentication or ACLs.

❓ Question: Does the 16 KiB application limit cap the whole MQTT packet?

💬 Answer: No. It caps the payload the application decodes. The topic and properties add packet bytes, and Paho has already received the packet before this check runs. Lesson 16 covers an earlier packet-size limit.

📨 Map encoding metadata into Paho

This fragment belongs where you build a paho.Publish; payload is the JSON produced by your contract encoder:

utf8Payload := byte(1)

// Describe the payload's encoding and media type for MQTT 5 consumers.
properties := &paho.PublishProperties{
    ContentType:   "application/json",
    PayloadFormat: &utf8Payload,
}
// Attach these properties to the PUBLISH packet built by your transport helper.
_ = properties

❓ Question: A message declares JSON, but its payload is hello. Is it valid JSON?

💬 Answer: No. Content Type describes the media type; Payload Format Indicator 1 says the bytes are UTF-8 text. Neither validates the content or proves its schema. Decode the bytes, and check an incoming property pointer for nil before reading it; nil means that property was omitted.

Keep MQTT properties with the received transport message when the application needs them. Fields such as schema_version and command_id belong in the JSON contract type. This lets handlers use the needed data without depending on a packet object that another part of the program may change.

🧪 Predict, modify, explain

Send a valid heartbeat, then change only its body device ID, then change only its schema version. Predict which validation stage rejects each. Finally publish valid JSON with an extra trailing object: one successful decoder call is insufficient unless you also require EOF.

Why can an invalid message still get a successful MQTT acknowledgement?

❓ Question: The broker delivers a sales report with quantity -3. Can the MQTT client acknowledge it even if the application rejects it?

💬 Answer: Yes. In this helper, the callback can return and the MQTT client may acknowledge delivery. The backend then records the validation failure and rejects the sale. PUBACK confirms the MQTT delivery step; it does not mean the sale succeeded.

📋 1. Create the contract package

❓ Question: What shared vocabulary lets the backend and device agree on the shape and meaning of each message?

The contract package gives both programs the same field names and meanings. Its topic.go file owns the hierarchy and distinguishes a concrete publish name from a subscription filter; message.go owns JSON payload types and their checks. For example, if the simulator sends device while the backend expects device_id, the message may be rejected or misunderstood.

mkdir -p internal/contracts

The complete implementation for this step is shown below. Save each block at its stated path, then update the backend and simulator to use these contracts before they handle or publish messages.

Save this as internal/contracts/topic.go. Section 4 introduced its basic purpose; here it is included in full so this lesson can be used on its own.

🔍 topic.go — file overview

This file owns the lab hierarchy lab/v1/devices/{deviceID}/{kind}. It builds concrete TopicName values for PUBLISH, fleet-wide and exact-device TopicFilter values for SUBSCRIBE, and parses an incoming name into a device ID and flow kind. Composition code still chooses the role's subscriptions, and Mosquitto ACLs still authorize them.

package contracts

import (
	"fmt"
	"strings"
)

type TopicName string
type TopicFilter string
type Kind string

const (
	KindTelemetry Kind = "telemetry"
	KindCommands  Kind = "commands"
	KindResponses Kind = "responses"
	KindSales     Kind = "sales"
)

const topicPrefix = "lab/v1/devices/"

type Topic struct {
	DeviceID string
	Kind     Kind
}

func (t TopicName) String() string   { return string(t) }
func (f TopicFilter) String() string { return string(f) }

func DeviceTopic(deviceID string, kind Kind) (TopicName, error) {
	if !validID(deviceID) {
		return "", fmt.Errorf("invalid device ID %q", deviceID)
	}
	if !supportedKind(kind) {
		return "", fmt.Errorf("unsupported topic kind %q", kind)
	}
	return TopicName(topicPrefix + deviceID + "/" + string(kind)), nil
}

func DeviceFilter(kind Kind) (TopicFilter, error) {
	if !supportedKind(kind) {
		return "", fmt.Errorf("unsupported topic kind %q", kind)
	}
	return TopicFilter(topicPrefix + "+/" + string(kind)), nil
}

func DeviceSubscription(deviceID string, kind Kind) (TopicFilter, error) {
	topic, err := DeviceTopic(deviceID, kind)
	if err != nil {
		return "", err
	}
	return TopicFilter(topic.String()), nil
}

func ParseTopic(value string) (Topic, error) {
	if !strings.HasPrefix(value, topicPrefix) {
		return Topic{}, fmt.Errorf("topic must be %s{deviceID}/{kind}", topicPrefix)
	}
	parts := strings.Split(strings.TrimPrefix(value, topicPrefix), "/")
	if len(parts) != 2 || !validID(parts[0]) {
		return Topic{}, fmt.Errorf("topic must be %s{deviceID}/{kind}", topicPrefix)
	}
	kind := Kind(parts[1])
	if !supportedKind(kind) {
		return Topic{}, fmt.Errorf("unsupported topic kind %q", kind)
	}
	return Topic{DeviceID: parts[0], Kind: kind}, nil
}

func supportedKind(kind Kind) bool {
	switch kind {
	case KindTelemetry, KindCommands, KindResponses, KindSales:
		return true
	default:
		return false
	}
}

func validID(value string) bool {
	if value == "" || len(value) > 128 {
		return false
	}
	for _, r := range value {
		if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || strings.ContainsRune("._-", r)) {
			return false
		}
	}
	return true
}

🧱 DeviceTopic, DeviceFilter, and DeviceSubscription — build the right kind of path

Summary: Build a concrete publish name, a fleet-wide flow filter, or an exact per-device subscription filter.

Details: DeviceTopic never inserts wildcard characters. DeviceFilter places one + wildcard at the device level for a known flow; DeviceSubscription reuses the validated name to produce an exact filter. The backend uses fleet-wide filters, while the simulator subscribes only to its configured device. These Go types reduce accidental mixing; broker ACLs remain the authorization boundary.

🧭 ParseTopic — recover routing data

Summary: Check the shared topicPrefix, then validate the remaining device ID and flow kind.

Details: ParseTopic first requires the incoming name to start with topicPrefix, then splits only the suffix into exactly two levels. The parser does not repeat the prefix's individual segments, so changing the constant updates builders and parsing together. Parsing establishes which contract to decode and lets the application compare topic identity with payload identity. It does not prove which client published the message.

Save the next block as internal/contracts/message.go. This file defines the versioned payloads and value checks. Topic hierarchy and parsing live in topic.go. A command ID remains stable across retries; an MQTT packet identifier identifies only one protocol exchange.

🔍 message.go — file overview

This file defines JSON payload types and their validation rules. Its validation functions check the relationship between parsed topic data and the body before a decoded value can reach a handler.

package contracts

import (
	"fmt"
	"time"
)

const SchemaVersion = 1

// These types define the JSON fields shared by backend and simulator.
type Heartbeat struct {
	SchemaVersion int    `json:"schema_version"`
	MessageID     string `json:"message_id"`
	DeviceID      string `json:"device_id"`
	OccurredAt    string `json:"occurred_at"`
	Temperature   *float64 `json:"temperature,omitempty"`
}

type Command struct {
	SchemaVersion int    `json:"schema_version"`
	MessageID     string `json:"message_id"`
	DeviceID      string `json:"device_id"`
	OccurredAt    string `json:"occurred_at"`
	CommandID     string `json:"command_id"`
	Command       string `json:"command"`
}

type CommandResult struct {
	SchemaVersion int    `json:"schema_version"`
	MessageID     string `json:"message_id"`
	DeviceID      string `json:"device_id"`
	OccurredAt    string `json:"occurred_at"`
	CommandID     string `json:"command_id"`
	Status        string `json:"status"`
	Detail        string `json:"detail,omitempty"`
}

type SalesLine struct {
	SKU          string `json:"sku"`
	Quantity     int64  `json:"quantity"`
	AmountMinor  int64  `json:"amount_minor"`
}

type SalesReport struct {
	SchemaVersion int         `json:"schema_version"`
	ReportID      string      `json:"report_id"`
	DeviceID      string      `json:"device_id"`
	OccurredAt    string      `json:"occurred_at"`
	Items         []SalesLine `json:"items"`
}

// validateEnvelope checks fields shared by every versioned message.
func validateEnvelope(topic Topic, version int, messageID, deviceID, occurredAt string, now time.Time) error {
	if version != SchemaVersion {
		return fmt.Errorf("unsupported schema_version %d", version)
	}
	if !validID(messageID) || !validID(deviceID) {
		return fmt.Errorf("message_id and device_id must be valid IDs")
	}
	if topic.DeviceID != deviceID {
		return fmt.Errorf("topic device %q does not match payload device_id %q", topic.DeviceID, deviceID)
	}
	stamp, err := time.Parse(time.RFC3339, occurredAt)
	if err != nil {
		return fmt.Errorf("occurred_at must be RFC3339: %w", err)
	}
	if stamp.After(now.Add(30 * time.Second)) {
		return fmt.Errorf("occurred_at is more than 30 seconds in the future")
	}
	return nil
}

// Each Validate method adds checks specific to one message kind.
func (m Heartbeat) Validate(topic Topic, now time.Time) error {
	if topic.Kind != KindTelemetry {
		return fmt.Errorf("heartbeat requires telemetry topic")
	}
	if err := validateEnvelope(topic, m.SchemaVersion, m.MessageID, m.DeviceID, m.OccurredAt, now); err != nil {
		return err
	}
	if m.Temperature != nil && (*m.Temperature < -50 || *m.Temperature > 100) {
		return fmt.Errorf("temperature is outside the supported range")
	}
	return nil
}

func (m Command) Validate(topic Topic, now time.Time) error {
	if topic.Kind != KindCommands {
		return fmt.Errorf("command requires commands topic")
	}
	if err := validateEnvelope(topic, m.SchemaVersion, m.MessageID, m.DeviceID, m.OccurredAt, now); err != nil {
		return err
	}
	if !validID(m.CommandID) || m.Command != "ping" {
		return fmt.Errorf("command_id is invalid or command is unsupported")
	}
	return nil
}

func (m CommandResult) Validate(topic Topic, now time.Time) error {
	if topic.Kind != KindResponses {
		return fmt.Errorf("command result requires responses topic")
	}
	if err := validateEnvelope(topic, m.SchemaVersion, m.MessageID, m.DeviceID, m.OccurredAt, now); err != nil {
		return err
	}
	if !validID(m.CommandID) || (m.Status != "accepted" && m.Status != "completed" && m.Status != "rejected") {
		return fmt.Errorf("command result has invalid command_id or status")
	}
	return nil
}

func (m SalesReport) Validate(topic Topic, now time.Time) error {
	if topic.Kind != KindSales {
		return fmt.Errorf("sales report requires sales topic")
	}
	if err := validateEnvelope(topic, m.SchemaVersion, m.ReportID, m.DeviceID, m.OccurredAt, now); err != nil {
		return err
	}
	if len(m.Items) == 0 || len(m.Items) > 100 {
		return fmt.Errorf("sales report must contain 1 to 100 items")
	}
	for i, item := range m.Items {
		if !validID(item.SKU) || item.Quantity <= 0 || item.AmountMinor < 0 {
			return fmt.Errorf("item %d has invalid SKU, quantity, or amount_minor", i)
		}
	}
	return nil
}

🪪 validateEnvelope — check shared payload fields

Summary: Apply identifier syntax, schema-version, topic/body device identity, and timestamp checks used by several message types.

Details: validateEnvelope checks fields shared by all payload contracts and takes now as an argument, allowing deterministic time validation. Topic-segment ID syntax is owned by topic.go. Neither helper authenticates the MQTT publisher; broker credentials and ACLs perform a separate job.

✅ Each Validate method — check one contract's rules

Summary: Enforce that the JSON type matches the topic kind and that message-specific values are supported.

Details: Heartbeat.Validate checks optional temperature bounds; Command.Validate accepts only the current harmless ping; CommandResult.Validate permits known result states; SalesReport.Validate checks item count and each SKU, quantity, and amount. The methods are called by Decode; handlers should not bypass them.

internal/contracts/decode.go bounds the payload before decoding, rejects unknown fields, requires the JSON value to consume the entire input, and calls the decoded contract's Validate method before returning it. MQTTMessage restricts the generic decoder to types with that method. The parsed topic and clock are explicit inputs so validation can check route/body identity and remain deterministic in tests.

package contracts

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"time"
)

const MaxPayloadBytes = 16 * 1024

// Decode bounds input, rejects unknown fields, requires one JSON value,
// and validates it against the topic before returning it.
func Decode[T MQTTMessage](payload []byte, topic Topic, now time.Time) (T, error) {
	var value T
	if len(payload) > MaxPayloadBytes {
		return value, fmt.Errorf("payload exceeds %d bytes", MaxPayloadBytes)
	}
	decoder := json.NewDecoder(bytes.NewReader(payload))
	decoder.DisallowUnknownFields()
	if err := decoder.Decode(&value); err != nil {
		return value, fmt.Errorf("decode JSON: %w", err)
	}
	var trailing any
	// Decode reads one JSON value at a time. A second call distinguishes
	// whitespace (EOF) from a second value or malformed trailing bytes.
	if err := decoder.Decode(&trailing); err != io.EOF {
		if err == nil {
			return value, fmt.Errorf("payload contains more than one JSON value")
		}
		return value, fmt.Errorf("trailing data after JSON value: %w", err)
	}
	if err := value.Validate(topic, now); err != nil {
		return value, fmt.Errorf("validate message: %w", err)
	}
	return value, nil
}

🔍 decode.go — file overview and function guide

This file performs bounded JSON decoding and contract validation. MQTTMessage is the type constraint: only message types with a Validate method can be passed to Decode.

📥 Decode — accept one valid contract value

Summary: Enforces the byte limit, decodes exactly one JSON object, rejects unknown fields, then calls the selected type's validator.

Details: The second decoder call distinguishes trailing whitespace from a second JSON value or malformed trailing bytes. The function returns no validated value until every structural and business check succeeds. This protects application state from malformed data, but a valid value still needs an application service to decide what to do with it; Section 5A adds that missing step.

internal/contracts/decode_test.go checks malformed boundaries without a broker:

package contracts

import (
	"strings"
	"testing"
	"time"
)

// Topic tests reject malformed routes and unsupported message kinds.
func TestParseTopic(t *testing.T) {
	topic, err := ParseTopic("lab/v1/devices/device-001/sales")
	if err != nil { t.Fatal(err) }
	if topic.DeviceID != "device-001" || topic.Kind != KindSales {
		t.Fatalf("unexpected topic: %#v", topic)
	}
	for _, invalid := range []string{"lab/v1/devices/+/sales", "lab/v1/devices/device-001/unknown", "lab/v1/devices/device-001/sales/extra"} {
		if _, err := ParseTopic(invalid); err == nil { t.Errorf("ParseTopic(%q) succeeded", invalid) }
	}
}

// These cases cover strict JSON shape and single-value decoding.
func TestDecodeRejectsUnknownAndTrailingValues(t *testing.T) {
	topic := Topic{DeviceID: "device-001", Kind: KindTelemetry}
	now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
	if _, err := Decode[Heartbeat]([]byte(`{"schema_version":1,"unknown":true}`), topic, now); err == nil {
		t.Fatal("unknown field was accepted")
	}
	if _, err := Decode[Heartbeat]([]byte(`{"schema_version":1,"message_id":"m-1","device_id":"device-001","occurred_at":"2026-10-13T12:00:00Z"} {"message_id":"m-2"}`), topic, now); err == nil {
		t.Fatal("trailing JSON value was accepted")
	}
}

// This case ensures the application limit is applied before decoding.
func TestDecodeRejectsOversizedPayload(t *testing.T) {
	payload := []byte(`"` + strings.Repeat("x", MaxPayloadBytes) + `"`)
	if _, err := Decode[Heartbeat](payload, topic, now); err == nil {
		t.Fatal("oversized payload was accepted")
	}
}

// A body cannot override the device selected by the topic.
func TestCommandRejectsTopicIdentityMismatch(t *testing.T) {
	topic, err := ParseTopic("lab/v1/devices/device-001/commands")
	if err != nil { t.Fatal(err) }
	command := Command{SchemaVersion: SchemaVersion, MessageID: "m-1", DeviceID: "device-002", OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: "c-1", Command: "ping"}
	if err := command.Validate(topic, time.Now().UTC()); err == nil {
		t.Fatal("topic/body identity mismatch was accepted")
	}
}

🏷️ 2. Parse topics before routing

❓ Question: A message arrives with both a topic and a payload. Why decide which device and message kind the topic represents before running a handler?

contracts.ParseTopic in topic.go accepts exactly lab/v1/devices/{deviceID}/{kind}. It rejects unsafe IDs, extra segments, wildcard characters, and unknown message kinds. Use it before decoding so the validator can compare the route's device ID with the payload claim. Add the sales ACL rules only when you publish sales reports.

✅ 3. Decode and validate

❓ Question: JSON decoding succeeded. What else must be true before this message is safe for application state?

💬 Answer: Decoding checks syntax, not whether the message is safe to use. The backend also checks size, schema, required IDs, timestamps, values, and agreement between the topic and claimed device before business handling.

contracts.Decode[T] in decode.go applies the 16 KiB payload limit, rejects unknown JSON fields, requires EOF after one value, and calls Validate(topic, now) before returning. MQTTMessage restricts T to contract types that implement validation. Validation checks schema version 1, required IDs, supported commands, topic/body device agreement, RFC3339 timestamps, and message-specific values. Passing now explicitly lets callers use a fixed clock in tests. Historical sales are allowed; the validator does not apply heartbeat freshness rules to reports.

Metadata describes the payload, and the ID in it is only a claim. The backend validates the bytes and compares that ID with the topic. Broker credentials and ACLs separately authorize which sender may publish to the topic.

🧪 4. Integrate and verify

❓ Question: Where should validation sit so malformed messages cannot trigger a response or change state?

Run validation after receiving the message but before business handling. Otherwise, a malformed command could already change state or trigger a reply. Test accepted and rejected messages, then test broker ACL permissions separately; application validation does not decide who may publish to a topic.

For one sales message, the processing order is:

topic, err := contracts.ParseTopic(message.Topic)
if err != nil {
	return err
}
report, err := contracts.Decode[contracts.SalesReport](message.Payload, topic, time.Now().UTC())
if err != nil {
	return err
}
// Only a validated report reaches the business handler.

Decode reads one JSON value per call. After decoding the expected report, it calls Decode again: io.EOF means only whitespace remained, another successful decode means there was a second JSON value, and a different error means malformed trailing bytes. Only after that structural check does it call the report's Validate method.

Replace Section 4's anonymous JSON structs in both programs with these contracts. The complete executable files are below; each validates inbound data before logging, changing state, or sending a result. Rejections log a bounded error, not the full payload.

cmd/backend/main.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"
)

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()
	telemetryFilter, err := contracts.DeviceFilter(contracts.KindTelemetry)
	if err != nil { return fmt.Errorf("build telemetry filter: %w", err) }
	responsesFilter, err := contracts.DeviceFilter(contracts.KindResponses)
	if err != nil { return fmt.Errorf("build responses filter: %w", err) }
	salesFilter, err := contracts.DeviceFilter(contracts.KindSales)
	if err != nil { return fmt.Errorf("build sales filter: %w", err) }
	commandTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindCommands)
	if err != nil { return fmt.Errorf("build command topic: %w", err) }
	// Start subscriptions before handling any delivered message.
	inbox := make(chan mqtt.Message, 16)
	manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{telemetryFilter, responsesFilter, salesFilter}, inbox)
	if err != nil {
		return fmt.Errorf("start MQTT: %w", err)
	}
	defer mqtt.Stop(manager)

	ticker := time.NewTicker(10 * time.Second)
	defer ticker.Stop()
	sequence := 0
	for {
		select {
		case <-ctx.Done():
			return nil
		case message := <-inbox:
			if err := handleMessage(message); err != nil {
				log.Printf("rejected topic=%q reason=%v", message.Topic, err)
			}
		case <-ticker.C:
			sequence++
			id := fmt.Sprintf("ping-%d-%d", time.Now().UTC().Unix(), sequence)
			command := contracts.Command{SchemaVersion: contracts.SchemaVersion, MessageID: id, DeviceID: cfg.MQTT.DeviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: id, Command: "ping"}
			payload, err := json.Marshal(command)
			if err != nil {
				return fmt.Errorf("encode command: %w", err)
			}
			if err := mqtt.Publish(ctx, manager, commandTopic, payload); err != nil {
				log.Printf("command publish failed or uncertain id=%s: %v", id, err)
			}
		}
	}
}

func handleMessage(message mqtt.Message) error {
	// Parse and validate before a message can reach application handling.
	topic, err := contracts.ParseTopic(message.Topic)
	if err != nil {
		return err
	}
	now := time.Now().UTC()
	switch topic.Kind {
	case contracts.KindTelemetry:
		value, err := contracts.Decode[contracts.Heartbeat](message.Payload, topic, now)
		if err != nil { return err }
		log.Printf("valid heartbeat device=%s message=%s", value.DeviceID, value.MessageID)
	case contracts.KindResponses:
		value, err := contracts.Decode[contracts.CommandResult](message.Payload, topic, now)
		if err != nil { return err }
		log.Printf("command result device=%s command=%s status=%s", value.DeviceID, value.CommandID, value.Status)
	case contracts.KindSales:
		value, err := contracts.Decode[contracts.SalesReport](message.Payload, topic, now)
		if err != nil { return err }
		log.Printf("valid sales report device=%s report=%s items=%d", value.DeviceID, value.ReportID, len(value.Items))
	default:
		return fmt.Errorf("backend does not consume topic kind %q", topic.Kind)
	}
	return nil
}

🧭 main, run, and handleMessage — validate backend traffic

Summary: main reports startup failure, run owns the connection and receive loop, and handleMessage routes each topic kind to its typed decoder.

Details: The receive loop logs rejected messages without crashing the process. handleMessage parses the topic before decoding; that parsed kind chooses the expected payload type, and Decode checks the body against the route. The default branch refuses kinds the backend does not consume. At this stage successful sales and results are only logged; Section 5A creates application state, while Sections 7 and 13 add matching and durable sales storage.

cmd/simulator/main.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"
	"github.com/eclipse/paho.golang/autopaho"
)

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()
	// Construct routes before connecting so invalid identity fails at startup.
	commandFilter, err := contracts.DeviceSubscription(cfg.MQTT.DeviceID, contracts.KindCommands)
	if err != nil { return err }
	telemetryTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindTelemetry)
	if err != nil { return err }
	responseTopic, err := contracts.DeviceTopic(cfg.MQTT.DeviceID, contracts.KindResponses)
	if err != nil { return err }
	inbox := make(chan mqtt.Message, 16)
	manager, err := mqtt.Start(ctx, cfg, []contracts.TopicFilter{commandFilter}, inbox)
	if err != nil { return fmt.Errorf("start MQTT: %w", err) }
	defer mqtt.Stop(manager)
	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, telemetryTopic, payload); err != nil { log.Printf("heartbeat publish: %v", err) }
		case message := <-inbox:
			if message.Retain { log.Printf("reject retained command topic=%q", message.Topic); continue }
			if err := respond(ctx, manager, cfg.MQTT.DeviceID, message); err != nil { log.Printf("reject command topic=%q reason=%v", message.Topic, err) }
		}
	}
}

func respond(ctx context.Context, manager *autopaho.ConnectionManager, deviceID string, message mqtt.Message) error {
	// Check route and body identity before constructing a command result.
	topic, err := contracts.ParseTopic(message.Topic)
	if err != nil { return err }
	command, err := contracts.Decode[contracts.Command](message.Payload, topic, time.Now().UTC())
	if err != nil { return err }
	if topic.DeviceID != deviceID { return fmt.Errorf("command is for another device") }
	result := contracts.CommandResult{SchemaVersion: contracts.SchemaVersion, MessageID: "result-"+command.CommandID, DeviceID: deviceID, OccurredAt: time.Now().UTC().Format(time.RFC3339), CommandID: command.CommandID, Status: "completed"}
	payload, err := json.Marshal(result)
	if err != nil { return fmt.Errorf("encode result: %w", err) }
	responseTopic, err := contracts.DeviceTopic(deviceID, contracts.KindResponses)
	if err != nil { return fmt.Errorf("build response topic: %w", err) }
	return mqtt.Publish(ctx, manager, responseTopic, payload)
}

📟 respond — validate a command and publish its result

Summary: Parses the command topic, validates its payload, confirms the target device, then publishes a completed result.

Details: Each check returns before result construction if the topic or body is invalid. The simulator only demonstrates a harmless ping here; it does not execute a real vending-machine operation. Section 7 adds retained-message rejection, response-topic authorization, Correlation Data, and duplicate suppression.

🧭 main and run — operate the simulator

Summary: main turns failures into a nonzero exit; run loads the simulator identity, subscribes to commands, publishes heartbeats, and calls respond for each inbound packet.

Details: run owns the signal context, manager, ticker, and receive loop. It keeps network callback work separate from contract validation and result publication. The heartbeat ticker is independent of command reception, so an idle device still reports recent contact. Section 6 moves potentially slow message work into bounded workers.

gofmt -w internal/contracts cmd

go test ./internal/contracts

go test ./...

mkdir -p creates the package directory. gofmt -w formats the complete files above. go test ./internal/contracts runs the supplied pure parser/validator tests without a broker; go test ./... checks the rest of the application too.

When adding sales, edit lab/mosquitto/acl: add topic read lab/v1/devices/+/sales under backend and topic write lab/v1/devices/device-001/sales under simulator. Restart the lab broker to read the changed ACL, check logs, and test allowed and cross-device-denied publications. Permissions enable a topic direction; they do not replace sales validation.

The supplied tests cover topic parsing, malformed JSON boundaries, payload size, and topic/body identity. Add a case when you change a contract so the test captures the new rule. Keep the simulator and backend contracts in sync when changing the wire format.

Done when: Both programs still exchange ping/pong, every rejection has a predictable reason, and invalid sales or commands leave state unchanged. Explain why schema validation does not authenticate a device.

✅ Result — the story so far

The backend and simulator now have a shared vocabulary for heartbeats, commands, results, and sales reports. When a message arrives, the receiver checks its topic, size, JSON shape, schema version, identifiers, timestamp, and topic/body device match before changing application state. A broker-routed message can still be rejected by the application; successful MQTT delivery alone does not make its contents valid or trusted.

Previous: 04-publish-subscribe-simulator.md. Next: 05A — Backend message processing.