diff --git a/diagrams/plantuml/ab-storage-layout.puml b/diagrams/plantuml/ab-storage-layout.puml
new file mode 100644
index 0000000..7091ca0
--- /dev/null
+++ b/diagrams/plantuml/ab-storage-layout.puml
@@ -0,0 +1,43 @@
+@startuml
+title A/B storage: logical areas and access responsibilities
+left to right direction
+skinparam shadowing false
+skinparam monochrome true
+
+actor "Installer" as installer
+actor "Boot control" as boot
+actor "OTA manager /\nconfirmation program" as manager
+actor "Running system\nand applications" as runtime
+
+rectangle "Device storage (logical areas, not a partition table)" {
+ rectangle "Shared boot components\nSeparate update and recovery method" as shared
+ database "Boot state\nNormal slot, trial target, attempts, confirmation" as state
+ rectangle "Slot A: retained system\nKernel + device tree + root filesystem" as A
+ rectangle "Slot B: update target\nKernel + device tree + root filesystem" as B
+ database "Persistent device data" as data {
+ rectangle "Identity and calibration\nRestricted access" as identity
+ rectangle "Configuration and application data\nMust support the retained version" as appdata
+ rectangle "OTA progress, failure blocks\nand pending reports" as progress
+ }
+ rectangle "Download and work area\nDisposable preparation files" as work
+}
+
+installer --> B : replace inactive slot
+installer --> work : use temporary files
+boot --> shared : execute
+boot --> state : read / save before Linux starts
+boot --> A : select matching components
+boot --> B : select matching components
+manager --> state : request trial / confirm\nthrough platform interface
+manager --> progress : read / save
+runtime --> appdata : read / write
+runtime --> identity : authorized access only
+
+legend bottom
+ A is running while B is prepared. Preserve A, boot state and device data.
+ Persistent device data survives replacement of either system slot.
+ Shared data is not rolled back by selecting A.
+ Both system versions must understand the recovery records they read.
+ Separate logical areas do not guarantee safe writes or whole-device recovery.
+endlegend
+@enduml
diff --git a/diagrams/plantuml/ab-trial-boot.puml b/diagrams/plantuml/ab-trial-boot.puml
new file mode 100644
index 0000000..88edaca
--- /dev/null
+++ b/diagrams/plantuml/ab-trial-boot.puml
@@ -0,0 +1,52 @@
+@startuml
+title A/B trial boot and recovery
+skinparam shadowing false
+skinparam monochrome true
+
+state "A confirmed" as A
+state "Prepare B\nA remains the normal boot choice" as Preparing
+state "B prepared and verified" as Prepared
+state "B trial pending\nSaved target and attempt limit" as Pending
+state "B trial running\nUnconfirmed" as Trial
+state "B confirmed" as Confirmed
+state "Recovery to A pending" as Recovering
+state "A recovery running" as Recovery
+state "A recovery checked" as Recovered
+state "Separate recovery required" as Failed
+
+[*] --> A
+A --> Preparing : begin installation
+Preparing --> Preparing : reset during preparation; boot A\nB is not eligible for trial
+Preparing --> A : abandon incomplete or failed preparation
+Preparing --> Prepared : installation and verification complete
+Prepared --> Pending : save trial request through boot control
+Pending --> Trial : reset [attempts left > 0]; save attempts left - 1\nbefore handing control to B
+Trial --> Confirmed : local checks pass within time limit;\nsave confirmation
+Trial --> Recovering : local checks fail; save recovery request
+Trial --> Pending : reset before confirmation\n(power loss or covered watchdog)
+Pending --> Recovering : reset; no attempts left
+Recovering --> Recovery : boot control selects A on reset
+Recovery --> Recovered : local recovery checks pass;\nsave outcome
+Recovery --> Failed : recovery checks fail or startup fails\n(platform recovery policy)
+Confirmed --> [*]
+Recovered --> [*]
+Failed --> [*]
+
+note right of Pending
+ Boot state is outside A and B.
+ Attempts are consumed before starting B.
+ Invalid records require a platform-defined
+ recovery path, never implicit confirmation.
+end note
+
+note right of Recovery
+ A must still be compatible with shared data.
+ Do not endlessly alternate failed systems.
+end note
+
+legend bottom
+ Preparation interruption assumes the installer preserves A and boot state.
+ State transitions assume valid durable records; torn writes need platform testing.
+ Checks and recovery are local; reports wait for connectivity.
+endlegend
+@enduml
diff --git a/diagrams/plantuml/ota-architecture.puml b/diagrams/plantuml/ota-architecture.puml
new file mode 100644
index 0000000..7966c82
--- /dev/null
+++ b/diagrams/plantuml/ota-architecture.puml
@@ -0,0 +1,38 @@
+@startuml
+title OTA roles and device boundary
+left to right direction
+skinparam shadowing false
+skinparam monochrome true
+skinparam packageStyle rectangle
+
+package "Release and deployment services" {
+ component "Build, test and publish" as release
+ component "Artifact storage" as artifacts
+ component "Deployment management" as deployment
+}
+
+package "Device" {
+ component "OTA manager" as manager
+ component "Installer" as installer
+ component "Boot control" as boot
+ component "Local health checks" as checks
+ database "Persistent update progress\nand pending reports" as progress
+}
+
+release --> artifacts : publish artifact and\nverification information
+release --> deployment : register release and compatibility
+manager --> deployment
+manager --> installer : authorize preparation
+installer --> artifacts : download and verify
+manager --> boot : request trial through\nplatform interface
+checks --> boot : confirm after local checks pass\nor request recovery after failure
+manager --> progress : save / resume progress;\nqueue reports while offline
+manager --> checks : arrange checks after activation
+
+legend bottom
+ OTA manager -> deployment management: request releases; report running version and outcome.
+ Boot control runs before the OTA manager and handles exhausted trial attempts.
+ Hangs need a covered reset path. Checks and recovery do not require the server.
+ Roles may be combined in one tool. This view uses a system update with trial boot.
+endlegend
+@enduml
diff --git a/diagrams/readme.md b/diagrams/readme.md
new file mode 100644
index 0000000..3add3e5
--- /dev/null
+++ b/diagrams/readme.md
@@ -0,0 +1,17 @@
+# OTA diagrams
+
+PlantUML sources accompany chapters 03–05. The diagrams describe the illustrative A/B design, not a required platform or partition table.
+
+| View | Source | Preview |
+|---|---|---|
+| Roles and the server/device boundary | [PlantUML](plantuml/ota-architecture.puml) | [SVG](rendered/ota-architecture.svg) |
+| Trial boot, confirmation and recovery | [PlantUML](plantuml/ab-trial-boot.puml) | [SVG](rendered/ab-trial-boot.svg) |
+| Storage areas and access responsibilities | [PlantUML](plantuml/ab-storage-layout.puml) | [SVG](rendered/ab-storage-layout.svg) |
+
+From the repository root, regenerate previews with a local PlantUML installation and Graphviz:
+
+```sh
+JAVA_TOOL_OPTIONS=-Djava.awt.headless=true plantuml -tsvg -o ../rendered diagrams/plantuml/*.puml
+```
+
+Keep sources and previews together when changing a diagram. No external rendering service is needed. State transitions assume valid durable records; these pictures do not establish power-loss safety. The [A/B simulation](../examples/simulations/ab-trial-boot/readme.md) explores the same logical transitions.
diff --git a/diagrams/rendered/ab-storage-layout.svg b/diagrams/rendered/ab-storage-layout.svg
new file mode 100644
index 0000000..3954b2c
--- /dev/null
+++ b/diagrams/rendered/ab-storage-layout.svg
@@ -0,0 +1,79 @@
+
\ No newline at end of file
diff --git a/diagrams/rendered/ab-trial-boot.svg b/diagrams/rendered/ab-trial-boot.svg
new file mode 100644
index 0000000..3ee0798
--- /dev/null
+++ b/diagrams/rendered/ab-trial-boot.svg
@@ -0,0 +1,81 @@
+
\ No newline at end of file
diff --git a/diagrams/rendered/ota-architecture.svg b/diagrams/rendered/ota-architecture.svg
new file mode 100644
index 0000000..6316e52
--- /dev/null
+++ b/diagrams/rendered/ota-architecture.svg
@@ -0,0 +1,69 @@
+
\ No newline at end of file
diff --git a/docs/00-series-overview.md b/docs/00-series-overview.md
index 7991051..d6714cc 100644
--- a/docs/00-series-overview.md
+++ b/docs/00-series-overview.md
@@ -71,7 +71,7 @@ The emphasis is always on understanding the underlying design rather than memori
## Repository Structure
-The structure below shows the planned repository. The `diagrams/` and `examples/` directories will be added as the practical material develops.
+The structure below includes the current documentation, PlantUML diagrams, and A/B simulation. Hardware demonstrations and later chapters will be added as the practical material develops.
```text
ota-reference-design/
@@ -86,12 +86,12 @@ ota-reference-design/
│ ├── ...
│
├── diagrams/
-│ └── plantuml/
+│ ├── plantuml/
+│ └── rendered/
│
├── examples/
-│ ├── raspberry-pi/
-│ ├── qemu/
│ └── simulations/
+│ └── ab-trial-boot/
│
├── adr/
│ ├── ADR-001-repository-name.md
@@ -196,6 +196,8 @@ Where possible, theoretical discussions are accompanied by practical material, i
The objective is to connect high-level architecture with practical implementation.
+Available now: [PlantUML diagrams and SVG previews](../diagrams/readme.md) and a [Python A/B trial boot simulation](../examples/simulations/ab-trial-boot/readme.md). Raspberry Pi and QEMU demonstrations are planned.
+
---
## Intended Audience
diff --git a/docs/03-system-architecture.md b/docs/03-system-architecture.md
index d388999..6e36432 100644
--- a/docs/03-system-architecture.md
+++ b/docs/03-system-architecture.md
@@ -45,6 +45,12 @@ Alongside the update files, it needs a few details: which version this is, which
These are roles, not a shopping list of separate services. One tool may cover several of them. What matters is knowing who handles each step.
+The following view shows the roles and the server/device boundary for the system-update example used in the next chapter.
+
+
+
+[PlantUML source](../diagrams/plantuml/ota-architecture.puml)
+
## 3. One Update, Start to Finish
Imagine a device running version 1. Version 2 is ready. In this example, the device can prepare the new system while keeping the previous one available for recovery.
diff --git a/docs/04-starting-an-updated-system-or-service.md b/docs/04-starting-an-updated-system-or-service.md
index f23c4ef..759f6e1 100644
--- a/docs/04-starting-an-updated-system-or-service.md
+++ b/docs/04-starting-an-updated-system-or-service.md
@@ -99,6 +99,12 @@ Returning to a previous version is called **rollback**. Keeping A available make
[ADR-014: Trial Boot and Confirmation for System Updates](../adr/ADR-014-trial-boot-and-confirmation.md) explains when to choose this policy and what the platform must support.
+The state diagram separates an explicit health-check failure from a startup failure that prevents the checks from running. It also shows the checks after returning to A.
+
+
+
+[PlantUML source](../diagrams/plantuml/ab-trial-boot.puml) · [Runnable simulation and failure scenarios](../examples/simulations/ab-trial-boot/readme.md)
+
## 5. Example: Starting an Updated Service
Now imagine a device with a stable system platform and an independently updated communication service. Version 1 works. Version 2 arrives as a package.
diff --git a/docs/05-storage-layout.md b/docs/05-storage-layout.md
index 1905afa..e5d9d59 100644
--- a/docs/05-storage-layout.md
+++ b/docs/05-storage-layout.md
@@ -64,6 +64,12 @@ The layout still has shared parts. If an update damages the shared boot componen
Two slots on one storage device also do not protect against failure of that whole device.
+The following diagram adds access responsibilities to the logical layout. A is running while the installer prepares B. Trial requests and confirmation use the platform's boot control interface.
+
+
+
+[PlantUML source](../diagrams/plantuml/ab-storage-layout.puml) · [Simulation of saved boot state across resets](../examples/simulations/ab-trial-boot/readme.md)
+
## 3. What Lives Outside the System Slots?
A system release provides common software. Device identity and calibration belong to an individual device. Installing a system image must not replace them with values from the release build.
diff --git a/examples/simulations/ab-trial-boot/CMakeLists.txt b/examples/simulations/ab-trial-boot/CMakeLists.txt
new file mode 100644
index 0000000..1f21b9a
--- /dev/null
+++ b/examples/simulations/ab-trial-boot/CMakeLists.txt
@@ -0,0 +1,23 @@
+cmake_minimum_required(VERSION 3.20)
+project(ab_trial_boot LANGUAGES CXX)
+set(CMAKE_CXX_EXTENSIONS OFF)
+
+add_library(ab-simulation simulation.cpp)
+target_compile_features(ab-simulation PUBLIC cxx_std_17)
+if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
+ target_compile_options(ab-simulation PRIVATE -Wall -Wextra -Wpedantic)
+endif()
+
+add_executable(ab-simulate main.cpp)
+target_link_libraries(ab-simulate PRIVATE ab-simulation)
+
+include(CTest)
+if(BUILD_TESTING)
+ add_executable(ab-simulation-tests tests.cpp)
+ target_link_libraries(ab-simulation-tests PRIVATE ab-simulation)
+ add_test(NAME recovery-invariants COMMAND ab-simulation-tests)
+ add_test(NAME all-scenarios COMMAND ab-simulate all)
+ add_test(NAME cli-help COMMAND ab-simulate --help)
+ add_test(NAME cli-invalid-scenario COMMAND ab-simulate invalid)
+ set_tests_properties(cli-invalid-scenario PROPERTIES WILL_FAIL TRUE)
+endif()
diff --git a/examples/simulations/ab-trial-boot/main.cpp b/examples/simulations/ab-trial-boot/main.cpp
new file mode 100644
index 0000000..f746d83
--- /dev/null
+++ b/examples/simulations/ab-trial-boot/main.cpp
@@ -0,0 +1,30 @@
+#include "simulation.hpp"
+
+#include
+#include
+#include
+
+int main(int argc, char* argv[]) {
+ const std::string selected = argc > 1 ? argv[1] : "all";
+ if (argc > 2 || selected == "--help" || selected == "-h" ||
+ (selected != "all" && std::find(ota::scenarios().begin(), ota::scenarios().end(),
+ selected) == ota::scenarios().end())) {
+ std::ostream& output = (argc == 2 && (selected == "--help" || selected == "-h"))
+ ? std::cout : std::cerr;
+ output << "Usage: ab-simulate [all";
+ for (const auto& scenario : ota::scenarios()) output << '|' << scenario;
+ output << "]\n";
+ return &output == &std::cout ? 0 : 2;
+ }
+ try {
+ for (const auto& scenario : ota::scenarios()) {
+ if (selected != "all" && selected != scenario) continue;
+ std::cout << "\n=== " << scenario << " ===\n";
+ ota::TemporaryDirectory directory;
+ ota::run_scenario(scenario, directory.path(), &std::cout);
+ }
+ } catch (const std::exception& error) {
+ std::cerr << "Simulation failed: " << error.what() << '\n';
+ return 1;
+ }
+}
diff --git a/examples/simulations/ab-trial-boot/readme.md b/examples/simulations/ab-trial-boot/readme.md
new file mode 100644
index 0000000..b4cf04d
--- /dev/null
+++ b/examples/simulations/ab-trial-boot/readme.md
@@ -0,0 +1,43 @@
+# A/B trial boot simulation
+
+This C++17 standard-library example follows the [trial boot diagram](../../../diagrams/plantuml/ab-trial-boot.puml), [chapter 04](../../../docs/04-starting-an-updated-system-or-service.md), and [ADR-014](../../../adr/ADR-014-trial-boot-and-confirmation.md). It illustrates boot decisions; it does not install software or access a device. No third-party libraries are required.
+
+Build and run from the repository root with CMake 3.20 or later and a C++17 compiler. These commands use a Linux temporary build directory to keep generated files outside the repository:
+
+```sh
+cmake -S examples/simulations/ab-trial-boot -B /tmp/ota-ab-build -DCMAKE_BUILD_TYPE=Debug
+cmake --build /tmp/ota-ab-build
+/tmp/ota-ab-build/ab-simulate all
+/tmp/ota-ab-build/ab-simulate attempts-exhausted
+ctest --test-dir /tmp/ota-ab-build --output-on-failure
+```
+
+| Scenario | Expected result |
+|---|---|
+| `success` | Checks pass; B is confirmed and selected on the next boot. |
+| `health-failure` | B fails checks; reset selects A; recovery checks pass. |
+| `attempts-exhausted` | B never reaches checks; two unconfirmed attempts lead to A. |
+| `restart-during-trial` | A reset preserves the consumed attempt; the second trial passes. |
+| `interrupted-preparation` | Incomplete B remains ineligible; reset selects A. |
+| `recovery-failure` | A also fails; stop at the boundary of a separate recovery policy. |
+
+With no argument, `ab-simulate` runs all scenarios. Use `--help` to list the accepted arguments. Invalid arguments return a nonzero exit status.
+
+The attempt limit of two is an illustrative choice. Each reset reconstructs the device from a versioned text record (`boot-state.txt`) in a temporary directory. The running slot is volatile. The saved normal slot stays A until B is confirmed. Boot control saves each consumed attempt before returning B as the selected system. A failed health check requests recovery immediately rather than spending the remaining trial attempts.
+
+The example keeps boot state outside the simulated slots, as shown in the [storage diagram](../../../diagrams/plantuml/ab-storage-layout.puml). Each scenario uses a separate temporary directory, removed when it finishes. The model covers one A-to-B update; it does not model subsequent releases.
+
+## Assumptions and limits
+
+Installation and artifact verification are represented by one preparation operation. Health checks are supplied boolean outcomes, including a timeout represented as failure. Resets are explicit calls; watchdog coverage and real startup timing are not simulated. Recovery assumes that A and shared data remain usable, unless the recovery-failure scenario supplies a failed check.
+
+The text file is a teaching stand-in for boot state, using standard C++ streams and `std::filesystem`. It records the phase, normal slot, optional trial slot, attempts remaining, slot readiness, and outcome. Replacement uses a temporary file and rename, but does not flush storage, implement redundant records, or recover invalid records. Unreadable records stop the simulation with an error; they are never treated as confirmation. This is not a power-loss-safe boot-state implementation. The simulation assumes completed valid writes; it does not test interruption within a write, coordination with OTA progress, data migration, signatures, bootloader integration, or hardware failure. These need platform-specific implementation and failure-injection tests.
+
+Outcomes remain local in the model. There is no server dependency or reporting transport. A real implementation must retain pending reports separately and send them when connectivity returns.
+
+## Source and checks
+
+- [simulation.hpp](simulation.hpp) and [simulation.cpp](simulation.cpp): typed state model, saved records, reset behavior, and scenarios.
+- [main.cpp](main.cpp): command-line runner.
+- [tests.cpp](tests.cpp): scenario outcomes and invariants for saved attempts, incomplete slots, confirmation, and unreadable state. Checks remain enabled in Release builds.
+- [CMakeLists.txt](CMakeLists.txt): builds the runner and registers the invariant and CLI checks with CTest.
diff --git a/examples/simulations/ab-trial-boot/simulation.cpp b/examples/simulations/ab-trial-boot/simulation.cpp
new file mode 100644
index 0000000..c5b43c1
--- /dev/null
+++ b/examples/simulations/ab-trial-boot/simulation.cpp
@@ -0,0 +1,233 @@
+#include "simulation.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace ota {
+namespace {
+constexpr const char* phase_names[] = {
+ "A confirmed", "Prepare B", "B prepared and verified", "B trial pending",
+ "B trial running", "B confirmed", "Recovery to A pending",
+ "A recovery running", "A recovery checked", "Separate recovery required"
+};
+
+Phase read_phase(const std::string& value) {
+ for (int i = 0; i < 10; ++i) {
+ if (value == phase_names[i]) return static_cast(i);
+ }
+ throw std::runtime_error("Invalid saved phase");
+}
+
+Slot read_slot(const std::string& value) {
+ if (value == "A") return Slot::A;
+ if (value == "B") return Slot::B;
+ throw std::runtime_error("Invalid saved slot");
+}
+
+void require(bool condition, const char* message) {
+ if (!condition) throw std::logic_error(message);
+}
+} // namespace
+
+const char* name(Phase phase) { return phase_names[static_cast(phase)]; }
+const char* name(Slot slot) { return slot == Slot::A ? "A" : "B"; }
+
+Device::Device(const std::filesystem::path& directory)
+ : path_(directory / "boot-state.txt") {
+ if (!std::filesystem::exists(path_)) {
+ save();
+ return;
+ }
+ std::ifstream input(path_);
+ std::string header, phase, normal, trial;
+ int a_ready = 0, b_ready = 0;
+ if (!(input >> header >> std::quoted(phase) >> normal >> trial
+ >> state_.attempts_left >> a_ready >> b_ready
+ >> std::quoted(state_.outcome)) || header != "OTA_AB_STATE_V1") {
+ throw std::runtime_error("Unreadable saved boot state");
+ }
+ input >> std::ws;
+ if (!input.eof() || state_.attempts_left < 0 ||
+ (a_ready != 0 && a_ready != 1) || (b_ready != 0 && b_ready != 1)) {
+ throw std::runtime_error("Invalid saved boot state");
+ }
+ state_.phase = read_phase(phase);
+ state_.normal = read_slot(normal);
+ if (trial != "none") state_.trial = read_slot(trial);
+ state_.a_ready = a_ready != 0;
+ state_.b_ready = b_ready != 0;
+}
+
+void Device::save() const {
+ auto temporary = path_;
+ temporary += ".tmp";
+ std::ofstream output(temporary, std::ios::trunc);
+ output << "OTA_AB_STATE_V1\n" << std::quoted(name(state_.phase)) << '\n'
+ << name(state_.normal) << '\n'
+ << (state_.trial ? name(*state_.trial) : "none") << '\n'
+ << state_.attempts_left << '\n'
+ << state_.a_ready << ' ' << state_.b_ready << '\n'
+ << std::quoted(state_.outcome) << '\n';
+ output.close();
+ if (!output) throw std::runtime_error("Cannot save boot state");
+ // Teaching stand-in: rename alone does not guarantee power-loss durability.
+ std::filesystem::rename(temporary, path_);
+}
+
+void Device::prepare(bool complete) {
+ require(state_.phase == Phase::AConfirmed || state_.phase == Phase::PreparingB,
+ "Cannot prepare B in this phase");
+ state_.b_ready = false;
+ state_.phase = Phase::PreparingB;
+ save();
+ if (complete) {
+ // Represents complete installation AND artifact verification.
+ state_.b_ready = true;
+ state_.phase = Phase::BPrepared;
+ save();
+ }
+}
+
+void Device::request_trial(int attempts) {
+ require(state_.phase == Phase::BPrepared && state_.b_ready,
+ "B must be prepared and verified before trial");
+ require(attempts > 0, "The trial needs at least one attempt");
+ state_.phase = Phase::TrialPending;
+ state_.trial = Slot::B;
+ state_.attempts_left = attempts;
+ save();
+}
+
+Slot Device::boot() {
+ require(state_.phase != Phase::SeparateRecoveryRequired,
+ "Separate recovery is required; do not alternate failed systems");
+ if (state_.trial) {
+ if (state_.attempts_left > 0) {
+ --state_.attempts_left;
+ state_.phase = Phase::TrialRunning;
+ // Persist the consumed attempt BEFORE handing control to B.
+ save();
+ running_ = *state_.trial;
+ return *running_;
+ }
+ state_.phase = Phase::RecoveryPending;
+ state_.trial.reset();
+ save();
+ }
+ if (state_.phase == Phase::RecoveryPending) {
+ state_.phase = Phase::RecoveryRunning;
+ save();
+ }
+ running_ = state_.normal;
+ return *running_;
+}
+
+void Device::check(bool healthy) {
+ require(running_.has_value(), "Boot before checking the running system");
+ require(state_.phase == Phase::TrialRunning || state_.phase == Phase::RecoveryRunning,
+ "Checks require a running trial or recovery");
+ if (state_.phase == Phase::TrialRunning) {
+ state_.trial.reset();
+ state_.attempts_left = 0;
+ if (healthy) {
+ state_.phase = Phase::BConfirmed;
+ state_.normal = Slot::B;
+ state_.outcome = "B confirmed";
+ } else {
+ state_.phase = Phase::RecoveryPending;
+ }
+ } else if (healthy) {
+ state_.phase = Phase::RecoveryChecked;
+ state_.outcome = "Recovered to A";
+ } else {
+ state_.phase = Phase::SeparateRecoveryRequired;
+ state_.outcome = "Recovery A failed";
+ }
+ save();
+}
+
+TemporaryDirectory::TemporaryDirectory() {
+ std::random_device random;
+ for (int attempt = 0; attempt < 100; ++attempt) {
+ auto candidate = std::filesystem::temp_directory_path() /
+ ("ota-ab-simulation-" + std::to_string(random()) + "-" + std::to_string(random()));
+ if (std::filesystem::create_directory(candidate)) {
+ path_ = std::move(candidate);
+ return;
+ }
+ }
+ throw std::runtime_error("Cannot create a unique temporary directory");
+}
+
+TemporaryDirectory::~TemporaryDirectory() {
+ std::error_code ignored;
+ std::filesystem::remove_all(path_, ignored);
+}
+
+const std::vector& scenarios() {
+ static const std::vector names = {
+ "success", "health-failure", "attempts-exhausted", "restart-during-trial",
+ "interrupted-preparation", "recovery-failure"
+ };
+ return names;
+}
+
+Device run_scenario(const std::string& scenario, const std::filesystem::path& directory,
+ std::ostream* output) {
+ if (std::find(scenarios().begin(), scenarios().end(), scenario) == scenarios().end()) {
+ throw std::invalid_argument("Unknown scenario: " + scenario);
+ }
+ Device device(directory);
+ auto show = [&](const std::string& event) {
+ if (!output) return;
+ const auto& state = device.state();
+ *output << event << ": " << name(state.phase) << "; normal=" << name(state.normal)
+ << "; attempts_left=" << state.attempts_left << "; running="
+ << (device.running() ? name(*device.running()) : "none") << '\n';
+ };
+ auto reset = [&] {
+ device = Device(directory);
+ const auto selected = device.boot();
+ show(std::string("Reset -> ") + name(selected));
+ };
+ auto message = [&](const char* text) { if (output) *output << text << '\n'; };
+
+ show("Initial state");
+ if (scenario == "interrupted-preparation") {
+ device.prepare(false);
+ show("Interrupted installation: B remains ineligible");
+ reset();
+ return device;
+ }
+ device.prepare();
+ show("Installation and verification complete");
+ device.request_trial(2);
+ show("Trial requested");
+ reset();
+ if (scenario == "attempts-exhausted" || scenario == "restart-during-trial") {
+ message("No confirmation: simulate a reset caused by power loss or watchdog");
+ reset();
+ }
+ if (scenario == "attempts-exhausted") {
+ message("B still cannot reach health checks; simulate another reset");
+ reset();
+ device.check(true);
+ show("Recovery checks pass");
+ } else if (scenario == "health-failure" || scenario == "recovery-failure") {
+ device.check(false);
+ show("B health checks fail");
+ reset();
+ device.check(scenario != "recovery-failure");
+ show("Recovery checks complete");
+ } else {
+ device.check(true);
+ show("B health checks pass; confirmation saved");
+ reset();
+ }
+ return device;
+}
+} // namespace ota
diff --git a/examples/simulations/ab-trial-boot/simulation.hpp b/examples/simulations/ab-trial-boot/simulation.hpp
new file mode 100644
index 0000000..d58b00e
--- /dev/null
+++ b/examples/simulations/ab-trial-boot/simulation.hpp
@@ -0,0 +1,68 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+namespace ota {
+
+enum class Phase {
+ AConfirmed, PreparingB, BPrepared, TrialPending, TrialRunning,
+ BConfirmed, RecoveryPending, RecoveryRunning, RecoveryChecked,
+ SeparateRecoveryRequired
+};
+
+enum class Slot { A, B };
+
+const char* name(Phase phase);
+const char* name(Slot slot);
+
+struct State {
+ Phase phase = Phase::AConfirmed;
+ Slot normal = Slot::A;
+ std::optional trial;
+ int attempts_left = 0;
+ bool a_ready = true;
+ bool b_ready = false;
+ std::string outcome;
+};
+
+class Device {
+public:
+ explicit Device(const std::filesystem::path& directory);
+ const State& state() const { return state_; }
+ std::optional running() const { return running_; }
+ void prepare(bool complete = true);
+ void request_trial(int attempts = 2);
+ Slot boot();
+ void check(bool healthy);
+
+private:
+ void save() const;
+ State state_;
+ std::filesystem::path path_;
+ // Volatile: reconstructing a Device requires another boot before checks.
+ std::optional running_;
+};
+
+// Own only a freshly created directory and clean it up at scope exit.
+class TemporaryDirectory {
+public:
+ TemporaryDirectory();
+ ~TemporaryDirectory();
+ TemporaryDirectory(const TemporaryDirectory&) = delete;
+ TemporaryDirectory& operator=(const TemporaryDirectory&) = delete;
+ const std::filesystem::path& path() const { return path_; }
+
+private:
+ std::filesystem::path path_;
+};
+
+const std::vector& scenarios();
+Device run_scenario(const std::string& scenario,
+ const std::filesystem::path& directory,
+ std::ostream* output = nullptr);
+
+} // namespace ota
diff --git a/examples/simulations/ab-trial-boot/tests.cpp b/examples/simulations/ab-trial-boot/tests.cpp
new file mode 100644
index 0000000..4416661
--- /dev/null
+++ b/examples/simulations/ab-trial-boot/tests.cpp
@@ -0,0 +1,124 @@
+#include "simulation.hpp"
+
+#include
+#include
+#include
+#include
+
+namespace {
+void expect(bool condition, const char* message) {
+ if (!condition) throw std::runtime_error(message);
+}
+
+template
+void expect_rejected(Operation operation) {
+ try {
+ operation();
+ } catch (const std::logic_error&) {
+ return;
+ }
+ throw std::runtime_error("Invalid operation was accepted");
+}
+
+void scenario_outcomes() {
+ struct Expected {
+ const char* scenario;
+ ota::Phase phase;
+ ota::Slot normal;
+ const char* outcome;
+ };
+ const Expected expected[] = {
+ {"success", ota::Phase::BConfirmed, ota::Slot::B, "B confirmed"},
+ {"health-failure", ota::Phase::RecoveryChecked, ota::Slot::A, "Recovered to A"},
+ {"attempts-exhausted", ota::Phase::RecoveryChecked, ota::Slot::A, "Recovered to A"},
+ {"restart-during-trial", ota::Phase::BConfirmed, ota::Slot::B, "B confirmed"},
+ {"interrupted-preparation", ota::Phase::PreparingB, ota::Slot::A, ""},
+ {"recovery-failure", ota::Phase::SeparateRecoveryRequired, ota::Slot::A,
+ "Recovery A failed"}
+ };
+ for (const auto& result : expected) {
+ ota::TemporaryDirectory directory;
+ auto device = ota::run_scenario(result.scenario, directory.path());
+ const ota::Device restored(directory.path());
+ const auto& state = restored.state();
+ expect(state.phase == result.phase, "Unexpected saved phase");
+ expect(state.normal == result.normal, "Unexpected normal slot");
+ expect(state.outcome == result.outcome, "Unexpected saved outcome");
+ expect(state.a_ready, "The retained slot was modified");
+ if (state.phase == ota::Phase::SeparateRecoveryRequired) {
+ expect_rejected([&] { device.boot(); });
+ }
+ }
+}
+
+void saved_attempts() {
+ ota::TemporaryDirectory directory;
+ ota::Device device(directory.path());
+ device.prepare();
+ device.request_trial(2);
+ for (int left : {1, 0}) {
+ device = ota::Device(directory.path());
+ expect(device.boot() == ota::Slot::B, "Expected trial boot of B");
+ const ota::Device restored(directory.path());
+ expect(restored.state().attempts_left == left, "Attempt was not saved before boot");
+ expect(restored.state().normal == ota::Slot::A, "B was confirmed prematurely");
+ }
+ device = ota::Device(directory.path());
+ expect(device.boot() == ota::Slot::A, "Exhausted trial did not return to A");
+ expect(device.state().phase == ota::Phase::RecoveryRunning, "Recovery checks skipped");
+}
+
+void incomplete_slot() {
+ ota::TemporaryDirectory directory;
+ ota::Device device(directory.path());
+ device.prepare(false);
+ device = ota::Device(directory.path());
+ expect(!device.state().b_ready, "Incomplete B was marked ready");
+ expect_rejected([&] { device.request_trial(); });
+ expect(device.boot() == ota::Slot::A, "Incomplete B was selected");
+}
+
+void confirmation_requires_boot() {
+ ota::TemporaryDirectory directory;
+ ota::Device device(directory.path());
+ device.prepare();
+ expect_rejected([&] { device.request_trial(0); });
+ expect_rejected([&] { device.request_trial(-1); });
+ device.request_trial();
+ expect_rejected([&] { device.check(true); });
+ device.boot();
+ ota::Device restored(directory.path());
+ expect(!restored.running(), "Running slot survived reconstruction");
+ expect_rejected([&] { restored.check(true); });
+ device.check(true);
+ expect_rejected([&] { device.check(false); });
+}
+
+void unreadable_state() {
+ ota::TemporaryDirectory directory;
+ {
+ std::ofstream output(directory.path() / "boot-state.txt");
+ output << "incomplete record\n";
+ }
+ try {
+ const ota::Device device(directory.path());
+ } catch (const std::runtime_error&) {
+ return;
+ }
+ throw std::runtime_error("Unreadable state was accepted");
+}
+} // namespace
+
+int main() {
+ try {
+ scenario_outcomes();
+ saved_attempts();
+ incomplete_slot();
+ confirmation_requires_boot();
+ unreadable_state();
+ std::cout << "All recovery invariant checks passed\n";
+ } catch (const std::exception& error) {
+ std::cerr << "Test failed: " << error.what() << '\n';
+ return 1;
+ }
+}
diff --git a/readme.md b/readme.md
index b5025e8..47907ba 100644
--- a/readme.md
+++ b/readme.md
@@ -22,6 +22,8 @@ This repository is not intended to be a production-ready framework. Instead, it
The repository is being developed incrementally as a structured series of articles, diagrams, architecture decisions, and practical examples.
+Start with the [series overview](docs/00-series-overview.md). The current practical material includes [PlantUML diagrams](diagrams/readme.md) and an [A/B trial boot simulation](examples/simulations/ab-trial-boot/readme.md) with startup and recovery failure scenarios.
+
## License