Production-Grade, Fail-Safe OTA for a Multi-Processor IoT Appliance

A complete blueprint for updating a fleet of connected appliances that contain an embedded Linux SoC, a real-time microcontroller, an on-device ML model, a display/UI, plus the cloud services and dashboards that talk to them.

This document is opinionated. It describes what a system that survives contact with 100,000 devices in the field actually looks like, and it enumerates the failure modes that will eventually happen to you, with a concrete mitigation for each one.


Table of Contents

The Product We Are Updating

  1. The System Under Update
  2. Non-Negotiable Design Principles
  3. The Release Model: Bundles, Manifests, Compatibility
  4. Device-Side Architecture
  5. Updating Embedded Linux (A/B Root Filesystems)
  6. Updating the Microcontroller Firmware
  7. Updating the ML Model
  8. Updating Cloud, API and Dashboard
  9. Health Gating and the Confirmation Contract
  10. Rollout Orchestration: Cohorts, Canaries, Circuit Breakers
  11. Security and Threat Model
  12. Failure Mode and Effects Analysis (the big table)
  13. Deadlock, Brick and Bootloop Escape Hatches
  14. Observability: What You Must Measure
  15. Testing: HIL Farm, Fault Injection, Chaos
  16. Operational Runbooks
  17. Reference Technology Stack
  18. Implementation Checklist

The Product We Are Updating

Before any of the update machinery makes sense, it helps to be concrete about the product itself.

The product is a connected appliance. Picture something roughly the size of a small washing machine or a commercial coffee machine, installed in homes or small businesses, plugged into mains power and joined to the customer’s Wi-Fi. It runs jobs or cycles on behalf of a user, it reads its physical environment through a set of sensors, and it acts on that environment through heaters, pumps, valves, motors or relays. There is no camera anywhere in the product, so every input is a scalar or a low-rate signal rather than a video stream. A screen on the front panel lets the user start a job, watch its progress and change settings. Tens of thousands of these units are already deployed, and the number grows every quarter.

Inside the enclosure there are two processors with very different jobs.

The real-time microcontroller owns physics. It samples the sensors at a fixed rate, drives the actuators with precise timing, and enforces the safety interlocks that must hold whatever else goes wrong. It has no operating system worth speaking of, its timing is deterministic, and it is the only part of the system trusted to decide that a heater must switch off right now. Its software is a firmware binary.

The embedded Linux SoC owns everything else. It runs the application logic that decides what cycle to execute, renders the user interface on the display, holds the connection to the cloud, buffers telemetry while offline, and hosts the machine learning model. It talks to the microcontroller over a serial link with a framed protocol, sending setpoints down and receiving sensor readings and status up. Its software is a Linux root filesystem plus a set of applications.

The machine learning model is small and runs on the Linux side. It consumes recent sensor values and predicts simple things, such as whether a component is drifting out of tolerance or whether the current cycle is likely to fail. It produces a score. It never commands an actuator directly, because a statistical model has no business driving physical hardware. Its software is a model file plus the metadata describing how the model expects to be fed.

Outside the box, the cloud terminates the device connections, ingests telemetry, stores fleet state and sends commands back down. The dashboard sits on top of that, showing engineering and support teams what the fleet is doing and giving operators the controls to act on it.

graph TB
  USER(["End user"])
  OPS(["Support / engineering"])

  subgraph BOX["The appliance, deployed in the field"]
    direction TB

    subgraph PHYS["Physical domain"]
      SENS["Sensors<br/>temperature, pressure, flow,<br/>vibration, current, humidity"]
      ACT["Actuators<br/>heaters, pumps, valves,<br/>motors, relays"]
    end

    subgraph RT["Real-time microcontroller: owns physics"]
      LOOP["Control loop<br/>fixed-rate sampling, PWM,<br/>deterministic timing"]
      SAFE["Safety interlocks<br/>hard limits independent of<br/>application logic"]
    end

    subgraph LINUX["Embedded Linux SoC: owns everything else"]
      APPL["Application logic<br/>cycle selection, scheduling,<br/>business rules"]
      UIX["Display and local UI"]
      MDL["ML inference<br/>predicts drift and failures<br/>from sensor history"]
      CONN["Connectivity<br/>TLS, telemetry spool,<br/>offline buffering"]
    end
  end

  subgraph CLOUD["Cloud"]
    ING["Device gateway<br/>+ telemetry ingest"]
    STATE["Fleet state,<br/>history, analytics"]
    CMD["Command and<br/>configuration service"]
  end

  DASH["Dashboard<br/>fleet health, per-device drill-down,<br/>operator controls"]

  USER --> UIX
  UIX --> APPL
  APPL -->|"setpoints, cycle commands"| LOOP
  LOOP -->|"sensor values, status, faults"| APPL
  SENS --> LOOP
  LOOP --> ACT
  SAFE -.->|"can always override"| ACT
  APPL --> MDL
  MDL -->|"advisory score"| APPL
  APPL --> CONN
  CONN <-->|"telemetry up, commands down"| ING
  ING --> STATE
  CMD --> ING
  STATE --> DASH
  DASH --> CMD
  OPS --> DASH

Four pieces of software are therefore in the field at once, and each has its own update story. The Linux root filesystem and applications are large and change often. The microcontroller firmware is small, changes rarely, and carries physical risk when it changes. The ML model is tiny, changes on its own cadence driven by data rather than features, and fails silently rather than loudly. The cloud and dashboard change constantly and reach every device at once.

Devices are online most of the time but not all of the time. Wi-Fi drops, routers reboot, customers go on holiday and unplug the appliance, and some units sit behind captive portals or corporate proxies that behave badly. An update system that assumes a stable connection will work in the lab and fail in the field.

The rest of this document is about how to change all four of those pieces of software, safely, across the whole fleet, without ever producing a device that cannot be recovered remotely.


1. The System Under Update

The same product, viewed through the lens of what can be updated and what happens when an update goes wrong.

The appliance is not one computer. It is a distributed system in a plastic box. Every box in the field is a separate availability domain that you cannot SSH into.

graph TB
  subgraph FIELD["Field: N devices"]
    subgraph DEV["Appliance"]
      subgraph LIN["Embedded Linux SoC"]
        APP["Application services<br/>(control loop, UI, connectivity)"]
        UI["Display / Local UI"]
        ML["ML inference runtime<br/>+ versioned model artifact"]
        UA["Update Agent<br/>(download, verify, install, confirm)"]
        BL["Bootloader<br/>(U-Boot / UEFI + slot state)"]
        STORE["Persistent data partition<br/>(config, logs, telemetry spool)"]
      end
      subgraph MCUB["Real-time MCU"]
        MBL["MCU Bootloader<br/>(A/B slots, CRC, rollback)"]
        MFW["MCU Firmware<br/>(sensor sampling, actuator PWM, safety interlocks)"]
      end
      SENS["Sensors"]
      ACT["Actuators"]
    end
  end

  APP <-->|"IPC / shared memory"| UI
  APP <--> ML
  APP <-->|"UART / SPI / CAN<br/>framed protocol"| MFW
  MFW --> ACT
  SENS --> MFW
  BL --> APP
  MBL --> MFW
  UA --> BL
  UA -->|"firmware transfer"| MBL

  subgraph CLOUD["Cloud"]
    MQ["Device Gateway<br/>(MQTT/TLS, mTLS auth)"]
    OTA["OTA Service<br/>(campaigns, cohorts, policy)"]
    REG["Device Registry / Shadow<br/>(reported vs desired state)"]
    ART["Artifact Store + CDN<br/>(signed bundles, range requests)"]
    TSDB["Telemetry / Metrics / Logs"]
    KMS["Signing Service / HSM"]
    DASH["Dashboard + Fleet Console"]
  end

  UA <-->|"campaign poll / status"| OTA
  APP <-->|"telemetry, commands"| MQ
  UA -->|"HTTPS range GET"| ART
  MQ --> TSDB
  OTA --> REG
  KMS -.->|"signs releases"| ART
  TSDB --> DASH
  OTA --> DASH

The five update domains

# Domain Update unit Failure blast radius Rollback cost
1 Linux OS + rootfs A/B rootfs image Whole device offline Cheap (reboot to other slot)
2 Applications / containers OCI images or app partition Device degraded Cheap
3 MCU firmware Signed binary into MCU slot Physical safety, actuator control Medium (MCU reboot, ~seconds of blind time)
4 ML model Model file + metadata Bad predictions, silent Cheap (swap file)
5 Cloud/API/Dashboard Container deploy Whole fleet at once Cheap but fleet-wide

The asymmetry that defines the design: the cloud is the easiest thing to update and the most dangerous, because a bad cloud deploy hits 100% of devices in 30 seconds. The MCU is the hardest to update and the most physically dangerous. Design accordingly.


2. Non-Negotiable Design Principles

Every update is atomic and transactional. There is no state in which the device is halfway updated. Either the new version is fully committed or the old one is still running, and nothing in between is ever observable from outside.

Power can be cut at any instruction. Assume a user unplugs the appliance in the middle of a flash write. The device must still boot afterwards. Always.

Nothing installs without cryptographic verification. The signature is checked before install, the hash is re-checked once the write lands, and the bootloader verifies the image again at boot. Three checks at three different moments, because each one catches a different class of fault.

The device is the final authority on its own health. The cloud proposes and the device disposes. A device has to be able to reject an update and roll itself back with zero cloud contact, because the cloud is frequently the thing that is broken.

Rollback is a first-class feature rather than an emergency procedure. It runs in CI on every release, exercised exactly the way the forward path is.

Update the system rather than the components. Devices run a system release, which is a pinned tuple of rootfs, applications, MCU firmware and model. You ship tuples, never loose files.

Never trust “the update finished” as a success signal. Success means the device passed a functional health check after reboot and then confirmed itself.

Downgrade must be possible but never automatic across a security boundary. Anti-rollback counters block forced downgrade attacks, while a signed operator override handles the legitimate case where you genuinely need to go backwards.

Bandwidth is expensive and connectivity is flaky. Differential updates, resumable downloads and randomised jitter are requirements rather than optimisations.

The update path must be simpler than the thing it updates. The update agent, the bootloader and the recovery path carry the highest assurance requirements in the product. Keep them small, keep them boring and change them rarely.


3. The Release Model: Bundles, Manifests, Compatibility

3.1 System release

A system release is an immutable, signed manifest that names exact versions of every component and declares the compatibility constraints between them.

graph LR
  SR["System Release 2026.09.0<br/>signed manifest"] --> A["rootfs 5.4.1<br/>sha256:ab12…"]
  SR --> B["app-bundle 12.7.0<br/>sha256:cd34…"]
  SR --> C["mcu-fw 3.2.0<br/>sha256:ef56…"]
  SR --> D["model anomaly-v9<br/>sha256:7890…"]
  SR --> E["Compatibility rules<br/>min_bootloader, hw_rev, mcu_proto"]
  SR --> F["Migration steps<br/>schema v7 → v8"]

3.2 Manifest example

{
  "schema": 2,
  "release_id": "2026.09.0",
  "created_at": "2026-09-01T10:00:00Z",
  "compatible_hardware": ["APPL-A-rev3", "APPL-A-rev4"],
  "requires": {
    "min_bootloader_version": "2.1.0",
    "min_current_release": "2026.05.0",
    "mcu_protocol_range": [4, 6]
  },
  "anti_rollback_index": 17,
  "components": [
    {
      "name": "rootfs",
      "version": "5.4.1",
      "type": "raw-image",
      "target": "slot",
      "size": 412000000,
      "sha256": "ab12…",
      "delta_from": { "5.4.0": { "url": "…/rootfs-5.4.0-5.4.1.xdelta", "sha256": "…" } },
      "install_order": 20,
      "requires_reboot": true
    },
    {
      "name": "mcu-fw",
      "version": "3.2.0",
      "type": "mcu-binary",
      "protocol_version": 6,
      "sha256": "ef56…",
      "install_order": 30,
      "install_when": "actuators_idle",
      "requires_reboot": false
    },
    {
      "name": "ml-model",
      "version": "anomaly-v9",
      "type": "model",
      "runtime": "tflite>=2.14",
      "input_schema_hash": "9f2c…",
      "sha256": "7890…",
      "install_order": 40,
      "shadow_eval_hours": 72
    }
  ],
  "health_checks": ["boot_ok", "mcu_link_ok", "sensors_ok", "cloud_ok", "ui_ok", "model_ok"],
  "confirm_window_seconds": 900,
  "signature": {
    "alg": "ed25519",
    "key_id": "release-2026-Q3",
    "value": "…"
  }
}

3.3 Version compatibility is a matrix, not a line

You will eventually have devices on 8 different releases simultaneously. Every cloud API and every MCU protocol change must be evaluated against the oldest release still in the field, not against main.

Four rules keep this tractable. The MCU to Linux protocol is version-negotiated on the link handshake: Linux advertises a supported range [proto_min, proto_max], the MCU announces its own version, and the pair settles on the highest number both understand. Never ship a Linux application that speaks only the newest protocol.

The device to cloud API accepts additive changes only within a major version. Devices send their release ID on every connection so the gateway can shim behaviour for older builds.

The model to feature extractor binding is enforced by the input_schema_hash carried in the model artifact. The runtime refuses to load a model whose schema hash does not match the feature extractor it is paired with, which removes an entire class of silent corruption bugs.

Stepping stones handle the long tail. If 2026.09.0 cannot be applied directly on top of 2025.02.0, the OTA service computes an upgrade path such as 2025.02 -> 2025.11 -> 2026.09 and drives the device through each hop. Never let a device work this path out for itself.


4. Device-Side Architecture

4.1 Partition layout (Linux)

+---------------------------+
| Bootloader (U-Boot) + env |  redundant env copies, A/B
+---------------------------+
| Bootloader env backup     |
+---------------------------+
| rootfs_A   (read-only)    |  dm-verity protected
+---------------------------+
| rootfs_B   (read-only)    |  dm-verity protected
+---------------------------+
| appdata    (read-write)   |  ext4, journaled, survives updates
+---------------------------+
| models     (read-write)   |  A/B model dirs + active symlink
+---------------------------+
| mcu_fw     (read-write)   |  staging area for MCU images
+---------------------------+
| recovery   (read-only)    |  minimal initramfs + update agent
+---------------------------+
| factory    (read-only)    |  last-resort known-good, never written
+---------------------------+

Root filesystems are read-only and integrity-protected with dm-verity, so “the filesystem got corrupted by a bad flash sector” becomes a detectable and recoverable event instead of a mystery crash three weeks later. All mutable state lives in appdata, and if you cannot factory-reset a device by wiping exactly one partition then your layout is wrong. The factory partition is written once during manufacturing and never again. It is the floor you cannot fall through.

4.2 The update agent state machine

stateDiagram-v2
  [*] --> Idle
  Idle --> Checking: poll campaign / push notification
  Checking --> Idle: no update / not in cohort / blocked by policy
  Checking --> Downloading: manifest verified + compatible

  Downloading --> Downloading: resume after network drop
  Downloading --> Failed: hash mismatch / retries exhausted
  Downloading --> Verified: full bundle hash + signature OK

  Verified --> WaitingForWindow: device busy / user in session / actuator active
  WaitingForWindow --> Installing: safe window reached
  Verified --> Installing: safe now

  Installing --> Failed: write error / target rejects image
  Installing --> Installed: inactive slot written + verified byte-for-byte

  Installed --> Rebooting: mark slot "try", boot_count=0
  Rebooting --> Testing: booted into new slot

  Testing --> Confirmed: all health checks pass within window
  Testing --> RollingBack: health check fail / watchdog / confirm timeout
  Rebooting --> RollingBack: bootloader boot_count exceeded

  Confirmed --> Idle: report success, mark slot good
  RollingBack --> Idle: booted old slot, report failure + diagnostics
  Failed --> Idle: exponential backoff, report failure
  Failed --> Quarantined: N consecutive failures
  Quarantined --> Idle: operator clears / new release

4.3 Install ordering inside one transaction

Order matters enormously. The rule: install the hardest-to-reverse thing when everything else is already known good.

flowchart TD
  S["Start install"] --> D1["1. Download + verify ALL components<br/>Nothing is written until everything is on disk"]
  D1 --> D2["2. Write Linux rootfs to inactive slot"]
  D2 --> D3["3. Verify inactive slot hash (read back)"]
  D3 --> D4["4. Stage MCU firmware + model in appdata<br/>(not applied yet)"]
  D4 --> D5["5. Mark slot as TRY, set boot_count=0"]
  D5 --> D6["6. Reboot into new rootfs"]
  D6 --> D7["7. New rootfs runs early health checks"]
  D7 --> D8{"Linux healthy?"}
  D8 -->|No| RB["Roll back to old slot"]
  D8 -->|Yes| D9["8. Wait for actuator-idle window"]
  D9 --> D10["9. Push MCU firmware into MCU inactive slot"]
  D10 --> D11["10. MCU verifies CRC+signature, swaps, reboots"]
  D11 --> D12{"MCU link + self-test OK?"}
  D12 -->|No| MRB["MCU auto-reverts to previous slot<br/>Linux rolls back too"]
  D12 -->|Yes| D13["11. Activate new model in shadow mode"]
  D13 --> D14["12. Run full functional health suite"]
  D14 --> D15{"All green?"}
  D15 -->|No| RB
  D15 -->|Yes| C["13. CONFIRM: mark slot GOOD,<br/>bump anti-rollback, report to cloud"]
  RB --> R["Reboot old slot, report diagnostics"]
  MRB --> R

Why the reboot happens before the MCU update: rolling back Linux is free. Rolling back the MCU is not. If you update the MCU first and the Linux image turns out to be broken, you are now running old Linux against new MCU firmware, a combination nobody has ever tested. Prove Linux boots first, then touch the MCU.


5. Updating Embedded Linux (A/B Root Filesystems)

5.1 Why A/B and not in-place

Approach Power-fail safe Rollback Storage cost Verdict
Package manager (apt/opkg) in place Low Never for fleets
Single image + recovery partition ⚠️ (recovery must work) Slow Medium Acceptable minimum
A/B slots (dual-rootfs) Instant 2× rootfs Recommended
A/B + delta Instant 2× rootfs Recommended at scale
OSTree / atomic tree Instant ~1.3× Good for large, similar images

5.2 Bootloader responsibilities

The bootloader is the only component that can save you from a bad rootfs. Its logic must be trivial enough to be reviewed line by line.

/* Pseudocode: U-Boot boot slot selection, executed every boot */
slot = env.boot_order[0];               /* e.g. "A" */

for (i = 0; i < NUM_SLOTS; i++) {
    slot = env.boot_order[i];

    if (env.slot[slot].state == BAD)          continue;
    if (env.slot[slot].tries_remaining == 0) { env.slot[slot].state = BAD; continue; }

    if (!verify_signature(slot))         { env.slot[slot].state = BAD; continue; }

    if (env.slot[slot].state == TRY) {
        env.slot[slot].tries_remaining--;     /* decrement BEFORE booting */
        env_save_atomic();                    /* redundant env copies, CRC'd */
    }
    boot(slot);                               /* does not return */
}

boot_recovery();                              /* both slots unbootable */

Three details are routinely got wrong here. First, decrement the try counter before jumping to the kernel and persist it immediately. If you decrement only after a successful boot, a kernel panic loop runs forever. Second, the environment must be redundant and CRC-protected: two copies, write one, fsync, then write the other. A torn write to the only copy of the boot environment is a brick. Third, the bootloader itself should be updated almost never. If you genuinely must update it, use an SPL and second-stage split where only the second stage is writable, or a hardware-backed fallback such as a boot ROM alternate offset. A failed bootloader update means an RMA.

5.3 Delta updates

Full rootfs images run from 300 MB to 1 GB. Over cellular, across 100k devices, that becomes a budget line item rather than a technical detail.

Use binary deltas produced by xdelta3, bsdiff, casync/desync chunking or OSTree static deltas. Deltas are generated per source version, so publish them for the most common source versions and always keep the full image available as a fallback. Always verify the hash of the reconstructed image rather than the hash of the delta, because a correct delta applied to a subtly wrong source produces garbage that passes every check except the final one. Make your images reproducible so deltas stay small: non-deterministic builds with embedded timestamps, random inode ordering or leaked build paths will turn a 4 MB delta into a 300 MB one.

5.4 Resumable, polite downloads

GET /artifacts/rootfs-5.4.1.delta HTTP/1.1
Range: bytes=104857600-
If-Match: "sha256:ab12…"

Resume with HTTP range requests and persist download progress across reboots, since an appliance may well be power-cycled halfway through. Verify chunk-level hashes as you go so a corrupt CDN edge is caught at 5% rather than at 100%. Bandwidth-limit the transfer so the device stays responsive and does not saturate a customer’s home connection.

Above all, jitter everything. Randomise campaign poll times and download start times across a window of hours. A fleet of 100,000 devices that all wake at 03:00 UTC will run a denial of service attack against your own CDN and your own gateway.


6. Updating the Microcontroller Firmware

This is where physical harm lives. The MCU drives actuators and enforces safety interlocks.

6.1 MCU-side requirements

The MCU needs A/B slots in its flash, or failing that a bootloader plus a single application slot backed by a golden image fallback. A/B is strongly preferred wherever flash size allows it.

The MCU bootloader validates the application image on every boot. CRC32 is the bare minimum; signature verification is better, and most Cortex-M4 class parts and above have the cycles and ROM for ed25519 or ECDSA-P256 with the public key held in write-protected flash. An independent watchdog, armed by the bootloader before it jumps into the application, catches firmware that starts but never becomes healthy.

Rollback on failure to check in is the core safety property. After booting a new image the MCU treats it as pending until Linux sends an explicit FW_CONFIRM. If no confirmation arrives within N seconds the MCU reverts to the previous slot on its own. It never erases the running slot, which sounds obvious and is nevertheless a recurring bug.

Entering the bootloader must drive every actuator output to its defined safe state, with heaters off, valves closed and motors braked. Wherever possible this should be the hardware default rather than a software action. Pull-down resistors and enable pins that are inactive at reset mean even a bootloader crash leaves the appliance safe.

6.2 Transfer protocol

sequenceDiagram
  participant L as Linux Update Agent
  participant M as MCU Bootloader/App
  participant A as Actuators

  L->>M: GET_INFO
  M-->>L: {hw_rev, proto=6, active_slot=A, fw=3.1.0, free=B}
  L->>M: QUERY_SAFE_TO_UPDATE
  M-->>L: BUSY (heater active)
  Note over L,M: Wait for idle window (or user-approved maintenance window)
  L->>M: QUERY_SAFE_TO_UPDATE
  M-->>L: READY
  M->>A: Drive safe state, disable outputs
  L->>M: BEGIN_UPDATE(slot=B, size, sha256, fw_version=3.2.0)
  M-->>L: ACK (slot B erased)
  loop chunks of 1 to 4 KB
    L->>M: DATA(seq, payload, crc16)
    M-->>L: ACK(seq) / NAK(seq)
  end
  L->>M: END_UPDATE(signature)
  M->>M: Verify hash + signature over slot B
  alt Verification fails
    M-->>L: ERROR_VERIFY
    M->>M: Mark slot B invalid, keep running A
  else OK
    M-->>L: OK
    L->>M: SWAP_AND_RESET(pending=true)
    M->>M: Set boot_slot=B, state=TRY, reset
    M->>M: Bootloader validates B, boots, arms watchdog
    M-->>L: HELLO(fw=3.2.0, proto=6, state=TRY)
    L->>M: RUN_SELFTEST
    M-->>L: SELFTEST_RESULT(sensors ok, ADC ref ok, actuator drivers ok)
    alt Self-test + link OK
      L->>M: FW_CONFIRM
      M->>M: state=GOOD, slot A becomes spare
    else Anything wrong / no HELLO in 30 s
      M->>M: Watchdog/timeout → revert to slot A
      L->>L: Mark MCU update failed, roll back system release
    end
  end

Several details harden this protocol. Chunks are idempotent, sequenced and individually CRC-protected with explicit ACK and NAK, so a UART glitch costs one retransmitted chunk instead of the whole image. A session token issued in BEGIN_UPDATE and echoed in every chunk stops a reboot mid-transfer from splicing two different images together. The MCU bootloader enforces its own inactivity timeout: if no chunk arrives for 60 seconds it aborts the session and returns to the running application, because sitting in the bootloader forever waiting for a dead host is just a slower kind of brick. Version and hardware gating lives in the MCU as well as in Linux, so the MCU rejects firmware built for the wrong hardware revision even when Linux asks nicely.

6.3 The nastiest MCU case: bootloader-only device

If the MCU has no room for A/B slots, the sequence “erase app, then write app” opens a window in which power loss leaves no valid application at all. Three mitigations exist, in descending order of preference.

The best option is external SPI flash used as a staging area, with a bootloader that copies and verifies. Power loss during the copy is fully recoverable because the source image is still intact. Failing that, keep a minimal golden image in write-protected flash whose only capabilities are accepting new firmware over the wire and holding actuators in a safe state. At absolute minimum, the bootloader must detect an invalid application via CRC and enter a permanent recovery-listen mode. A device in that state is degraded, though it stays recoverable in the field rather than becoming an RMA.


7. Updating the ML Model

Model updates feel harmless. They are the most likely to fail silently and the least likely to be caught by a health check that only asks “did it boot”.

7.1 The model artifact is more than the weights

{
  "model_id": "anomaly-v9",
  "trained_at": "2026-08-20",
  "runtime": "tflite",
  "runtime_min_version": "2.14",
  "input_schema_hash": "9f2c…",
  "features": ["temp_c", "vibration_rms", "pressure_kpa", "duty_cycle"],
  "normalization": { "temp_c": { "mean": 41.2, "std": 8.7 } },
  "output": { "type": "score", "range": [0, 1], "threshold": 0.82 },
  "expected_latency_ms_p95": 12,
  "expected_memory_mb": 18,
  "training_data_window": "2025-06..2026-07",
  "offline_metrics": { "auc": 0.94, "fpr_at_95tpr": 0.03 },
  "sha256": "7890…"
}

The input_schema_hash carries most of the safety weight here. It hashes the ordered feature list together with the units and preprocessing applied to each one. The feature extractor in the application computes the same hash at startup, and a mismatch means the runtime refuses to load the model, falls back to the previous one and reports the discrepancy. This single check prevents the classic disaster in which feature order changed during training but never on the device, and the model quietly emits noise for six months while everyone admires the offline metrics.

7.2 Shadow mode is mandatory

flowchart LR
  F["Sensor features"] --> P["Model v8 (ACTIVE)<br/>drives decisions"]
  F --> S["Model v9 (SHADOW)<br/>output logged only"]
  P --> ACT["Actuator / alert decisions"]
  P --> T["Telemetry"]
  S --> T
  T --> CMP["Cloud: compare distributions<br/>agreement rate, drift, latency, memory"]
  CMP --> G{"v9 meets<br/>promotion gates?"}
  G -->|Yes| PR["Promote v9 to ACTIVE<br/>via signed release"]
  G -->|No| KILL["Abort, keep v8,<br/>alert ML team"]

Every promotion gate has to hold across the whole shadow window, typically 24 to 168 hours on a representative cohort. Agreement with the previous model must sit inside expected bounds, and agreement that is too high is equally suspicious because it usually means the new model was never actually loaded. The score distribution has to stay within a KS-divergence threshold of the offline expectation. The p95 inference latency and peak RSS have to fit the declared budget, since a model three times slower can starve the control loop. Finally, the downstream false-alarm rate must not rise on the subset of devices where ground truth is available.

7.3 Runtime safety rails

Inference runs in its own process under cgroup CPU and memory limits, so a model that runs out of memory cannot take the control application down with it. A timeout and circuit breaker wrap every call: when inference exceeds its budget the cycle is skipped and the deterministic fallback heuristic takes over. The control loop never blocks waiting on the model.

The model never commands actuators directly. It produces a score, and version-controlled deterministic logic combined with MCU-side interlocks decides what to actually do and enforces the limits. Model directories are arranged A/B with an atomic symlink swap, and the previous model stays on disk so a local rollback needs no network at all.

7.4 Runtime/model coupling

If anomaly-v9 needs TFLite 2.16 and the device rootfs ships 2.14, that model belongs to a rootfs release rather than a standalone model push. The manifest’s runtime_min_version makes the device refuse the mismatch instead of crashing on an unsupported op. Consider pinning the runtime version for the model’s entire lifetime and only upgrading runtimes as part of a full system release with HIL numerical-equivalence tests attached, since quantised ops do change their results between runtime versions.


8. Updating Cloud, API and Dashboard

Devices are spread across eight versions while the cloud runs exactly one. That makes the cloud the most conservative component in the system, and the one with the largest blast radius.

Contract tests run against every supported device release. CI spins up emulated devices for each release still in the field and runs them against the candidate cloud build. If the oldest supported release breaks, the deploy is blocked. Schema and API changes follow expand, migrate, then contract: add the new field, dual-write, backfill, migrate readers, and only remove the old field once the last device using it has retired. Those steps never happen in the same release.

The OTA service sits on the critical path for recovery, so it has to be the most available service you own. If the OTA control plane is down you cannot stop a bad rollout. Give it its own deployment cadence, its own error budget and no dependency whatsoever on the analytics stack. Apply progressive delivery to the cloud too, with blue/green or canary deployment on the device gateway and automatic rollback triggered by device-reported error rates.

Reconnect storms deserve specific attention because they are a self-inflicted outage. When the gateway restarts, every device reconnects at once. Exponential backoff with full jitter on the device is mandatory, alongside server-side connection rate limiting and a “come back at T plus X” hint in the disconnect frame.

The dashboard is an emergency brake as much as a viewer. Pause campaign, abort campaign and force-rollback-cohort each need to be one click, permissioned, audited, and rehearsed every quarter so that whoever reaches for them at 03:00 has done it before.


9. Health Gating and the Confirmation Contract

“Installed” ≠ “working”. The confirmation contract turns an update into a two-phase commit.

9.1 Layered health checks

Layer Check Timeout Failure action
L0 Bootloader Slot signature valid, try-counter n/a Boot other slot
L1 Early boot Kernel booted, rootfs verity OK, critical mounts 60 s Hardware watchdog reset → other slot
L2 Services All critical systemd units active, no crash loops 120 s Roll back
L3 Peripherals MCU link handshake, protocol negotiated, sensors returning plausible values, display initialised 180 s Roll back
L4 Connectivity TLS to gateway, clock sync, credentials valid, telemetry accepted 300 s Roll back (see caveat)
L5 Functional End-to-end self test: read sensor → decide → actuate no-op → observe feedback; model loads and scores a golden input to an expected value 600 s Roll back
L6 Soak No crashes, no memory growth, no thermal anomalies over the confirm window 15 min to 24 h Roll back

Caveat on L4: be very careful about making cloud connectivity a rollback trigger. If your gateway has an outage, every device mid-update rolls back simultaneously and you learn nothing except that you built a fleet-wide self-destruct button. The safer policy is that a connectivity failure extends the confirmation window and raises an alert, and only triggers rollback when local checks at layers L1 to L3 also implicate the update, or when the device had stable connectivity on the old release and now cannot connect after M attempts spread across hours rather than minutes.

9.2 The confirmation itself

/* Runs after all health checks pass. Order is critical. */
if (health_all_pass()) {
    mark_slot_good(current_slot);      /* 1. persist locally FIRST */
    set_boot_order(current_slot);      /* 2. make it default */
    bump_antirollback_if_required();   /* 3. only for security releases */
    report_to_cloud(SUCCESS, metrics); /* 4. cloud is informed last */
}

Local commit precedes cloud reporting. A device that is healthy but temporarily offline must not roll back a working update just because it could not phone home.

9.3 Watchdogs, layered

Watchdogs work in layers. The hardware watchdog, whether an SoC WDT or an external IC, is kicked by a userspace daemon whose own liveness depends on the health of the critical services. It must never be kicked by a bare loop in a cron job, because that proves only that the scheduler is running. The boot-count watchdog inside the bootloader covers kernel panics that happen before userspace exists at all. Application watchdogs use systemd WatchdogSec per service together with Restart=on-failure and StartLimitBurst, so a crash-looping service escalates into a system-level failure instead of hammering forever in place. The MCU keeps its own independent watchdog, which doubles as the last line of defence for actuator safety.


10. Rollout Orchestration: Cohorts, Canaries, Circuit Breakers

flowchart TD
  R["Release built + signed"] --> HIL["HIL farm: 20 real devices,<br/>all hardware revs, fault injection"]
  HIL -->|pass| INT["Internal fleet: ~50 devices<br/>dogfood, 48 h soak"]
  INT -->|pass| C1["Canary: 0.5% of fleet<br/>diverse geo/hw/connectivity<br/>24 h bake"]
  C1 --> G1{"Success ≥ 99%?<br/>Crash rate flat?<br/>No support ticket spike?"}
  G1 -->|No| HALT["HALT + auto-rollback cohort<br/>page on-call"]
  G1 -->|Yes| C2["Wave 1: 5%"]
  C2 --> G2{"Gates"}
  G2 -->|No| HALT
  G2 -->|Yes| C3["Wave 2: 25%"]
  C3 --> G3{"Gates"}
  G3 -->|No| HALT
  G3 -->|Yes| C4["Wave 3: 100%<br/>rate-limited by CDN + gateway capacity"]
  C4 --> DONE["Release GA<br/>keep N-1 artifacts hosted for rollback"]
  HALT --> RCA["Root cause, fix, new release"]

10.1 Cohort selection

Canaries have to be representative rather than convenient. Stratify them by hardware revision and component vendor, because that second ADC supplier will eventually bite you. Stratify by firmware history too, since devices that skipped three releases behave differently from devices that took every hop. Cover the full spread of connectivity, from fibre through flaky cellular to devices sitting behind a hostile corporate proxy, and spread across geographies and timezones to catch locale, clock and regulatory differences. Include both heavy and light usage, an appliance run twenty hours a day alongside one run for an hour a week. Deliberately include devices with known-marginal hardware and high prior error rates, because those are exactly the units that surface a marginal update.

Keep VIP customers, safety-critical installations, devices with known-bad connectivity and devices in the middle of a warranty claim out of the canary entirely.

10.2 Automatic circuit breakers

Halt the campaign automatically when, within a rolling window:

Signal Threshold (tune to your fleet)
Update failure rate > 2% of attempts
Rollback rate > 1% of installs
Devices not reporting back within 2× expected time > 1%
Post-update crash rate > 1.5× pre-update baseline
Devices dropping offline permanently > 0.1%. This is the brick detector, treat as SEV-1
Support ticket rate for updated cohort > 2× baseline
MCU-specific: self-test failures > 0.2%

The circuit breaker must be automatic and must not require the dashboard to be healthy. A human noticing at 03:00 is not a control system.

10.3 Device-side policy

Maintenance windows are user-configurable and default to local night hours, and an update never lands in the middle of an appliance cycle. Consent and deferral are handled on the display with a simple “update tonight?” prompt, a bounded number of deferrals, and a clearly labelled forced path for security-critical releases. Power gating matters whenever the device has a battery or unstable mains: require a minimum power state before install, and use a supercap or brown-out detector to gate flash writes where the hardware offers one. Never flash a device that is thermally throttling, and refuse to start at all when free space sits below the image size times 1.2.


11. Security and Threat Model

Threat Mitigation
Attacker serves malicious firmware Signature verification on device with keys in write-protected/OTP storage; TLS with certificate pinning is defence-in-depth, not a substitute for artifact signing
Compromised build server Sign in an HSM/KMS in a separate trust domain; reproducible builds; two-person release approval; transparency log of all released artifact hashes
Downgrade attack to a version with a known CVE Monotonic anti-rollback counter in OTP/eFuse or a secure monotonic counter; bumped only on security releases; documented, signed operator override for legitimate downgrades
Replay of an old valid campaign Nonce + expiry in the campaign token; device tracks last-seen campaign sequence number
Device impersonation to harvest firmware mTLS with per-device keys generated in a secure element at manufacture; never a shared fleet credential
Firmware extraction from artifact store Artifacts encrypted at rest and delivered over per-device signed URLs; encrypt the payload itself if IP protection matters (device decrypts with a key from the secure element)
Physical attack: reflash via JTAG/SWD Disable/lock debug ports in production; secure boot chain rooted in ROM; treat physical compromise of one device as a given and never let one device’s keys unlock the fleet
Malicious/compromised cloud operator Release signing separated from deployment permissions; every campaign action audited and immutable; rate limits on how many devices any single action can affect
Key compromise Key rotation plan that works over OTA: devices trust a set of keys, new keys are introduced signed by old, old keys are revoked in a later release. Test this before you need it. Keep an offline root key
Supply chain in the artifact SBOM per release, CVE scanning gate in CI, pinned dependencies, verified base images
Denial of service via update storm Server-side campaign rate limiting, per-device backoff with jitter, CDN capacity planning

Secure boot chain: ROM → bootloader (signed, verified by ROM) → kernel + dtb (signed, verified by bootloader) → rootfs (dm-verity root hash in signed kernel cmdline) → application signatures. Each link verifies the next. The MCU has its own parallel chain.


12. Failure Mode and Effects Analysis

This is the section to read twice. Everything here has happened to someone.

12.1 Download and transport

ID Failure mode Effect Detection Mitigation / Recovery
D1 Connection drops mid-download Wasted bandwidth, stalled update Byte count vs expected Resumable HTTP range requests; persist progress across reboots; exponential backoff with jitter
D2 Corrupted bytes from a bad CDN edge or flaky NAND cache Invalid image Chunk hashes + full-bundle hash Re-fetch failed chunk; after 3 failures, switch CDN edge/origin; blocklist the edge and alert
D3 Captive portal or transparent proxy returns HTML with 200 OK “Downloaded” garbage Content-Type + hash mismatch Hash check catches it; detect captive portals explicitly; pin TLS to reject MITM proxies
D4 Device on metered cellular Customer bill shock / carrier throttling Interface type + data budget counters Metered-connection policy: defer, or download deltas only, or require Wi-Fi; hard monthly data cap in the agent
D5 100k devices download at once CDN and gateway overload, self-DDoS Egress and connection metrics Randomised jitter over hours; server-side token bucket for campaign admission; rate-limited waves
D6 Artifact deleted from store while campaign is live Mass update failure 404 rate Immutable artifact retention policy (never delete a release referenced by any device); lifecycle rules that require an explicit retirement step
D7 Clock is wrong → TLS certificate rejected Cannot download anything TLS validation failure code RTC with battery; NTP with a fallback pool; allow a bounded clock-skew grace path for the update channel only; secure time from the gateway handshake
D8 Expired device certificate Device permanently locked out of the fleet Cert expiry monitoring Automated renewal well before expiry (renew at 50% of lifetime); alert on the fleet-wide expiry histogram; long-lived bootstrap credential as fallback
D9 DNS hijacked or resolver broken Cannot reach OTA service Resolution failures Multiple resolvers, hardcoded fallback IPs, DoH; signatures make hijack non-fatal

12.2 Storage and install

ID Failure mode Effect Detection Mitigation / Recovery
S1 Disk full mid-write Install fails, possibly corrupt slot statvfs before + ENOSPC Pre-flight free-space check with margin; log rotation and telemetry spool caps; a garbage-collect step that runs before every campaign
S2 Power loss during rootfs write Inactive slot corrupt Post-write read-back hash A/B: active slot untouched, device boots normally, retries later. This is why you never write the running slot
S3 Power loss during bootloader env write Brick Not detectable afterwards Redundant env copies with CRC, write-then-fsync-then-write-other; bootloader falls back to the valid copy
S4 eMMC/NAND wear-out or bad blocks Silent corruption over years SMART/eMMC health, ECC error counters, verity failures Report eMMC lifetime in telemetry; alert on devices approaching wear limits; minimise writes (read-only rootfs, log rate limits, noatime); proactive RMA before failure
S5 Filesystem corruption on the data partition Config lost, services fail Mount errors, fsck Journaled FS; critical config stored in duplicate with CRC and atomic rename; auto-fsck with a “reset to defaults and continue” path rather than refusing to boot
S6 Bit rot in an idle slot (unused for a year) Rollback target is corrupt Periodic background scrub Scrub both slots monthly; if the inactive slot is bad, re-provision it from the network before you need it
S7 Delta applied to the wrong base Garbage image that “installed successfully” Final image hash check Verify source image hash before applying a delta; verify result hash after
S8 Read-back verification skipped for speed Undetected bad flash None by design Never skip it. Read back and hash the written slot. It costs seconds and saves trucks

12.3 Boot and runtime

ID Failure mode Effect Detection Mitigation / Recovery
B1 New kernel panics on boot Bootloop Bootloader try counter Auto-rollback after N tries; capture panic log to a persistent ring buffer (pstore/ramoops) and upload after rollback
B2 Kernel boots, userspace hangs Device appears on but dead Hardware watchdog not kicked WDT reset → try counter decrements → rollback
B3 New kernel lacks a driver for a hardware variant Sensors/display dead on a subset of devices L3 peripheral health check Health check catches it and rolls back; hardware-diverse canary cohort catches it before wave 1
B4 Device boots healthy but the confirm daemon crashes Healthy device rolls itself back Confirm timeout Make confirmation the simplest possible code path; separate it from the app; alert on “rolled back but health was green”
B5 Health check itself is buggy (false negative) Good updates roll back fleet-wide Rollback rate spike in canary Health checks are versioned, unit-tested and HIL-tested; canary catches it at 0.5%
B6 Health check is too permissive (false positive) Broken update confirmed and promoted Post-confirm crash/telemetry anomalies Long soak gate (L6) after confirm; post-confirm regression detection can still trigger a forward fix or a cloud-commanded rollback
B7 Memory leak appears only after days Gradual fleet degradation, OOM RSS trend telemetry per service Soak in the internal fleet ≥ 1 week; memory-growth alarms; cgroup limits so one service dies instead of the system
B8 Both slots become bad Device unbootable Bootloader falls through Boot recovery partition; if that fails, boot factory partition; recovery can pull a fresh image over the network
B9 Time jumps backwards after NTP sync Timers fire wrong, TLS oddities, log confusion Clock-step events Use CLOCK_MONOTONIC for all timeouts and watchdogs, never wall clock
B10 Update lands mid appliance-cycle User’s job ruined, possible mess Device busy state Never install while a cycle is running; QUERY_SAFE_TO_UPDATE gate + user-visible maintenance window

12.4 MCU-specific

ID Failure mode Effect Detection Mitigation / Recovery
M1 Power loss mid MCU flash MCU has no valid app Bootloader CRC check A/B slots; or bootloader enters recovery-listen mode; actuators default-safe at reset by hardware
M2 UART noise corrupts firmware chunks Bad image Per-chunk CRC + final signature NAK/retransmit; final signature verification before swap
M3 New MCU firmware speaks a newer protocol than Linux Link dead, device inert Handshake version negotiation Version negotiation with an overlap range; never ship an MCU protocol break in the same release as anything else; Linux always supports N and N-1
M4 New MCU firmware has a subtle timing regression Actuator misbehaviour, possible safety issue Self-test + runtime plausibility checks + hardware interlocks HIL testing with real actuators and scope-verified timing; MCU-side hard limits independent of firmware logic; hardware interlocks (thermal fuse, pressure relief) that no firmware can override
M5 MCU updated, Linux rolls back → mismatched pair Untested combination in the field Version pair reported in telemetry Install MCU after Linux is proven; on Linux rollback, also revert the MCU; treat “unknown version pair” as a health failure
M6 MCU stuck in bootloader waiting for a host that never comes Device inert Bootloader session timeout 60 s inactivity timeout → return to app; if no valid app, blink a diagnostic code on the display/LED
M7 Linux crashes mid-transfer Partial write Session token + slot state MCU aborts session, erases target slot, keeps running current; Linux restarts transfer from scratch
M8 Brown-out during MCU reset causes partial register init Erratic behaviour BOR/POR flags read at startup Enable brown-out detection at a safe threshold; log reset cause in telemetry (reset-cause histograms are a superb early warning)

12.5 ML model

ID Failure mode Effect Detection Mitigation / Recovery
L1 Feature order/units changed in training but not on device Silently wrong predictions input_schema_hash mismatch Refuse to load; fall back to previous model; alert
L2 Model too slow or too large for the SoC Control loop starvation, OOM Latency/RSS budgets in the manifest, measured at load Reject at load if over budget; run inference in a cgroup-limited process; circuit breaker skips inference
L3 Distribution shift, for example a model trained on old sensor hardware Degrading accuracy over months Drift monitoring on input feature distributions and output scores Continuous drift dashboards; retraining triggers; per-hardware-revision model variants
L4 Model trained on data from a buggy firmware version Learns the bug Data lineage tagged with device release Tag every training row with firmware/model versions; exclude known-bad windows; require lineage review before training
L5 Runtime upgrade changes quantised op results Different predictions, same model file Golden-input regression test on device Golden vector test at load: score a fixed input, compare to expected within tolerance; failure → reject model
L6 Model outputs used directly to drive an actuator Physical risk from a statistical artifact Design review Model outputs advisory scores only; deterministic policy + MCU interlocks make the actual decisions
L7 New model is “better” offline, worse in the field Regression in customer experience Shadow-mode comparison Mandatory shadow window with promotion gates before activation

12.6 Cloud, fleet and process

ID Failure mode Effect Detection Mitigation / Recovery
C1 Bad cloud deploy breaks the device protocol Entire fleet offline at once Connection count cliff Contract tests vs all supported releases; canary the gateway; auto-rollback on connection-rate drop; devices keep working offline
C2 OTA service outage during a bad rollout Cannot stop the campaign Health checks OTA control plane is tier-0 with independent deploys; a “global pause” flag stored in a separate, dead-simple, highly available store
C3 Device shadow/desired-state loop Devices flap between versions Version oscillation detector Desired state is a single immutable release ID; devices report before/after; server-side loop breaker after 3 transitions
C4 Wrong cohort targeted (e.g. hw_rev filter typo) Incompatible firmware sent to wrong hardware Device-side hardware compatibility check Device refuses incompatible manifests, because the last line of defence belongs on the device; plus campaign dry-run showing affected device counts and a two-person approval for > X devices
C5 Device offline for 18 months, comes back on an ancient release Cannot upgrade directly; may not even have valid certs Version reported on connect Stepping-stone upgrade paths; long-lived bootstrap credentials; keep old artifacts hosted forever; a documented “ancient device” onboarding path
C6 Fleet-wide simultaneous rollback triggered by a cloud outage Mass churn, support storm Rollback-rate alarm Never make transient cloud connectivity a rollback trigger by itself (see §9.1 caveat)
C7 Telemetry pipeline saturated by a bad release logging at debug level Blind during an incident; cost spike Ingest volume alarms Per-device rate limits enforced at the gateway; sampling; log level controlled by signed remote config with a safe default
C8 Release built from an unclean tree / wrong branch Unknown code in the field Build provenance Reproducible builds, provenance attestation (SLSA-style), refuse to sign artifacts without a matching git tag and clean tree
C9 No one can tell what version a given device is on Impossible to debug or comply Absent unless you build it Version state is reported on every connect and every heartbeat; fleet inventory is queryable; the display shows the release ID for support calls
C10 Rollback artifact no longer exists Cannot roll back Retention audit Keep N-2 releases hosted and pinned; device keeps the previous slot locally so rollback needs no network at all
C11 Support/field team has no way to recover a device Truck roll or RMA Ticket analysis USB-stick recovery image, local recovery UI, service-mode over local network, documented and rehearsed
C12 Regulatory: update changes certified behaviour Compliance violation Release review checklist Classify releases (safety-relevant vs not); safety-relevant releases require a documented verification pack and possibly re-certification; keep an immutable audit trail of which device ran which software when

13. Deadlock, Brick and Bootloop Escape Hatches

Layered recovery, from cheapest to most expensive:

flowchart TD
  F["Update fails"] --> L1["Layer 1: Health check fails<br/>→ auto-rollback to previous slot<br/>Cost: one reboot"]
  L1 -->|"previous slot also bad"| L2["Layer 2: Bootloader try-counter exhausted on both slots<br/>→ boot RECOVERY partition<br/>Minimal system: network + update agent + UI"]
  L2 --> L2a["Recovery downloads a known-good full image,<br/>rewrites both slots, reboots"]
  L2 -->|"recovery corrupt"| L3["Layer 3: Boot FACTORY partition<br/>Write-once, never updated<br/>Restores the shipped release"]
  L3 -->|"no network available"| L4["Layer 4: Local recovery<br/>USB mass-storage image with a signed bundle,<br/>triggered by a button combo at power-on"]
  L4 -->|"storage device itself dead"| L5["Layer 5: Service mode<br/>JTAG/SWD or vendor recovery ROM<br/>Field technician / depot repair"]
  L5 --> RMA["RMA"]

Every layer has to be independently testable and actually tested in CI and on the HIL rig for every release. An untested recovery path is decorative. Recovery must run standalone with no dependency on the application rootfs, otherwise it shares fate with the thing it is recovering from.

The user has to get a signal. A device that is silently dead produces a support call carrying no information at all, while a device showing “Update failed, code E-204, retrying” on its display, or blinking a diagnostic pattern, produces an actionable one. Reserve a small status path, an LED pattern or a minimal framebuffer message, that keeps working even from recovery.

Repeated failures get quarantined. After three consecutive failed attempts on the same release the device stops trying, uploads a detailed diagnostic bundle and waits for operator action. A device retrying a doomed 500 MB download every fifteen minutes for a month is both a bandwidth bill and a flash wear problem.

Diagnostic bundle on failure

When an update fails the device collects and uploads a bundle on a best-effort basis, spooling it locally while offline. It carries the release IDs it came from, was heading to and is currently running for each component; the exact failure stage and error code; bootloader state, boot counts and the reset cause register; any kernel panic log recovered from persistent storage such as pstore or ramoops; the last 500 lines of the relevant journals; storage health, free space and download progress; MCU state with the last self-test results; and the network conditions observed during the attempt.

This bundle is what turns “0.7% of devices failed” from a mystery into a bug fix.


14. Observability: What You Must Measure

14.1 Fleet-level dashboard panels

Panel Why it matters
Version distribution over time (stacked area, per component) Shows rollout progress and stragglers
Update funnel: offered → downloaded → installed → confirmed Locates exactly which stage leaks devices
Rollback rate by release, hardware rev, and cohort The primary quality signal
Devices last seen > 24 h, trended against baseline The brick detector. Watch this like a hawk during rollouts
Time-to-update distribution (p50/p95/p99) p99 reveals the long tail of flaky-connectivity devices
Download bytes and CDN cost per campaign Catches delta regressions
Crash rate and OOM rate per release Post-confirm regression detection
eMMC lifetime histogram and reset-cause histogram Predicts hardware failures before they happen
MCU firmware version vs Linux version pairing matrix Detects mismatched pairs in the field
Model score distribution and shadow agreement Detects silent ML regressions
Support ticket rate per release cohort The signal your telemetry missed

14.2 Alerts that page a human

Page a human when the rollback rate crosses its threshold during an active campaign, when the “never came back” count exceeds baseline (treat that one as a SEV-1), or when the update failure rate climbs above the circuit-breaker threshold. Page when gateway connection count drops more than 10% inside five minutes. Page immediately on any device reporting a signature verification failure or an anti-rollback counter mismatch, since both suggest either an attack or a badly botched release. Page when the certificate expiry histogram shows a meaningful number of devices expiring within thirty days, because that failure arrives all at once and cannot be fixed after the fact.

14.3 The most important metric

Devices that stopped talking after an update. Every other metric is reported by the device, so it has survivorship bias built in. A device that bricked cannot tell you it bricked. Compute this from the expected population (registry) minus the observed population, per cohort, and alert on the delta.


15. Testing: HIL Farm, Fault Injection, Chaos

15.1 Hardware-in-the-loop farm

The farm is a rack of real devices covering every hardware revision and every component vendor variant. Programmable power supplies let you cut power at arbitrary points during an update. Network impairment gear introduces latency, packet loss, bandwidth caps, mid-transfer disconnects, captive portals and MITM proxies. Serial and JTAG access gives you post-mortem capability on units that fail to boot. Simulated sensors sit alongside real actuators with a scope or logic analyser on the MCU timing-critical lines. Running over the top of all of it, an automated loop continuously flashes devices back to old releases and upgrades them again.

15.2 Mandatory test matrix per release

Test Description
Upgrade from every supported release Including multi-step stepping-stone paths
Downgrade to N-1 Verify rollback path works
Power cut at 100 random points during the update Device must boot every time
Power cut specifically during: bootloader env write, MCU flash, model swap The three highest-risk windows
Network drop at 100 random points Resume must work
Corrupt bundle (flipped bits) Must be rejected, not installed
Wrong-signature bundle Must be rejected
Wrong-hardware bundle Must be rejected by the device
Disk full, 1 byte short Clean failure, device still healthy
Health check failure injection at each layer Rollback must trigger and complete
MCU update with actuators active Must be deferred, not forced
Model with mismatched schema hash Must be rejected, fall back
1000× repeated update cycle Flash wear and state-machine leak detection
Clock set to 2001 and to 2038 Update path must still function or fail safely
Both slots corrupted Recovery partition must take over
Recovery partition corrupted Factory partition must take over

15.3 Chaos in the field

Once the basics hold, run continuous low-level chaos on an opt-in internal cohort: random rollbacks, injected health failures, forced recovery boots. The goal is that recovery paths are exercised weekly in reality, not annually in theory.


16. Operational Runbooks

16.1 Emergency: bad release detected in the field

  1. Pause the campaign (single button, dashboard or CLI). Stops new devices from starting.
  2. Assess blast radius: how many devices installed, how many confirmed, how many are dark?
  3. If devices are self-rolling-back, do nothing more; let the mechanism work and monitor.
  4. If devices are stuck on the bad release (health checks passed but behaviour is wrong), push a cloud-commanded rollback to the affected cohort: set desired release = previous. Devices roll back using their local previous slot, so no download is needed.
  5. If devices are dark, this is a SEV-1. Determine whether they are unbootable or merely offline. Prepare a recovery-partition-based fix and a field-service plan.
  6. Freeze all releases, run the RCA, add a regression test that reproduces the failure, only then resume.

16.2 Cloud-commanded rollback

{
  "command": "set_desired_release",
  "target_cohort": "campaign:2026.09.0,status:installed",
  "release_id": "2026.08.2",
  "reason": "SEV-1: MCU link instability on hw_rev4",
  "force_local_slot_swap": true,
  "issued_by": "oncall@…",
  "signature": "…"
}

Devices that have the previous release in their inactive slot roll back in one reboot with zero download. This is the single highest-value capability in the entire system. Protect the inactive slot from being overwritten until the new release has been stable for a defined period.

16.3 Release checklist

  • Built from a clean, tagged tree; provenance attested
  • SBOM generated, CVE scan clean or exceptions approved
  • Signed by the release key in the HSM, two-person approval
  • Full HIL matrix green, including power-cut and rollback tests
  • Upgrade tested from every release still in the field
  • Cloud contract tests green against all supported device releases
  • Deltas generated and verified against each supported source version
  • N-1 and N-2 artifacts confirmed still hosted
  • Rollback rehearsed on the internal fleet
  • Circuit-breaker thresholds configured for this campaign
  • On-call briefed; release notes and known issues published
  • Cohort definition reviewed; dry-run device count matches expectation

17. Reference Technology Stack

Not the only valid choices, but a coherent set that works.

Concern Options
Linux build system Yocto (with meta-rauc/meta-mender) or Buildroot
A/B update engine RAUC, Mender, SWUpdate, or OSTree/libostree
Bootloader U-Boot with the bootcount + redundant-env pattern, or UEFI + systemd-boot
Integrity dm-verity for rootfs, fs-verity for individual files
Container apps (optional) Podman with systemd units, or balena/Docker with pinned digests
Signing ed25519 or ECDSA-P256; keys in cloud HSM/KMS; device trust anchor in OTP/secure element
Device identity Per-device key in a secure element (ATECC608, SE050) or TPM; mTLS
Transport MQTT over TLS for control, HTTPS + CDN for artifacts
Delta casync/desync, OSTree static deltas, or xdelta3
MCU bootloader MCUboot (mature, A/B, signature verification, well tested)
MCU RTOS Zephyr, FreeRTOS, or bare-metal with an explicit safety architecture
ML runtime TFLite Micro / TFLite / ONNX Runtime, pinned per release
Fleet backend AWS IoT Core + Jobs, Azure IoT Hub + Device Update, or self-hosted (EMQX/VerneMQ + custom OTA service)
Observability Prometheus/VictoriaMetrics + Grafana; OpenTelemetry from device to cloud
Artifact store S3/GCS with immutable object lock + CloudFront/Fastly

Build versus buy: below roughly 10k devices, with no unusual constraints, use a managed device-update service. The value in this document lies in the failure analysis, the health-gating contract, the MCU sequencing and the operational discipline, all of which you have to build yourself regardless of which engine sits underneath.


18. Implementation Checklist

Phase 1: Foundations (do not ship hardware without these)

  • A/B partition layout with a data partition and a factory partition
  • Bootloader with try-counter, redundant CRC’d environment, and slot selection
  • Secure boot chain rooted in ROM; signature verification on device
  • Per-device identity in a secure element, provisioned at manufacture
  • MCU bootloader with A/B slots and image verification (MCUboot)
  • Hardware watchdog wired and enabled
  • Recovery partition that can pull a full image over the network
  • Actuators default to a safe state at reset, by hardware

Phase 2: Update pipeline

  • Signed system-release manifests with compatibility constraints
  • Update agent implementing the full state machine, resumable downloads
  • Layered health checks + the confirmation contract
  • MCU transfer protocol with sequencing, CRC, session tokens, and confirm/revert
  • Model artifact format with schema hash, golden-vector test, and shadow mode
  • Diagnostic bundle collection and upload on failure

Phase 3: Fleet operations

  • Device registry with reported/desired release state
  • Campaign service with cohorts, waves, and gates
  • Automatic circuit breakers independent of the dashboard
  • Cloud-commanded rollback using the local previous slot
  • Fleet dashboard with the funnel, rollback rate, and dark-device detector
  • Alerting and on-call runbooks

Phase 4: Assurance

  • HIL farm with programmable power and network impairment
  • Full test matrix in CI, gating every release
  • Chaos cohort exercising recovery paths continuously
  • Key rotation rehearsed end to end
  • Field-service recovery procedure documented and rehearsed

Closing: the three things that actually matter

Everything above collapses into three properties. Hold all three and you will survive. Miss any one of them and you will eventually roll a truck to a customer’s house.

The device can always boot something. A/B slots, a redundant bootloader environment, recovery and factory partitions, and hardware-safe actuator defaults together guarantee there is always a next thing to run.

The device decides its own health locally and can roll back with no network. The cloud is an optimisation for the update path and never a dependency for recovery.

You can stop and reverse a rollout in minutes, and you can see the devices that stopped answering. Automatic circuit breakers cover the first half, and a dark-device detector covers the second, because the broken devices are precisely the ones incapable of reporting that they are broken.