Decisions¶
Append-only log of meaningful choices made about the tobor codebase. Each entry captures not only what we picked but
why, what we gave up, and what would make us reopen the question.
Future readers should be able to reconstruct the state of mind at the time of the call. Do not edit past entries in place. If a decision is reversed or superseded, add a new entry that references the old one.
Entry format¶
### D-NNNN: <short title>
Date: YYYY-MM-DD
Author: <name>
Status: proposed | accepted | superseded by D-XXXX
Context.
What forced the decision. What we knew at the time.
Alternatives considered.
- Option A: description. Trade-off.
- Option B: description. Trade-off.
- Option C: description. Trade-off.
Choice.
What we picked and why it beat the alternatives.
Consequences.
What this commits us to. What we are giving up.
Revisit trigger.
The concrete signal that would reopen this decision.
Log¶
D-0001: OS baseline is Ubuntu 22.04¶
Date: 2026-08-26 Author: Ambarish Status: accepted
Context. The fleet includes humanoid (G1, H2) and quadruped (Go2, A2, AS2) Unitree models. Humanoids ship on JetPack 6.x with Ubuntu 22.04. Some Go2 units in the field are still on JetPack 5.x with Ubuntu 20.04. Current development focus is humanoid-only. The baseline OS choice affects compiler features (gcc 9.4 vs 11.4), TLS library API (OpenSSL 1.1.1 vs 3.0), and CMake features (3.16 vs 3.22).
Alternatives considered.
- Ubuntu 20.04 baseline. Broadest coverage, works on every current Unitree unit. Costs an OpenSSL 1.1.1 compat shim
from day one,
g++ 9.4quirks such as-lstdc++fsfor<filesystem>, CMake 3.16 ceiling, and a doubled CI matrix. - Ubuntu 22.04 baseline. Newer toolchain, simpler code, cleaner C++17 support. Fielded 20.04 units are out of scope until they upgrade.
- Track both with two build profiles. Doubles CI complexity and platform-specific bug surface without a matching payoff while focus is humanoid-only.
Choice. Ubuntu 22.04 as the baseline for now. Current development focus is humanoid-only, and every humanoid in scope (G1, H2) ships on JetPack 6.x / Ubuntu 22.04.
The code is written as if 20.04 support will be added later, without paying the cost today:
- All TLS and HTTP calls that touch OpenSSL go through a thin internal compat header (
src/compat/openssl_compat.hpp) from the first line of TLS code. Today only the OpenSSL 3.0 branch is implemented. CMakeLists.txtadds-lstdc++fsas a conditional link flag guarded onCMAKE_CXX_COMPILER_VERSION VERSION_LESS 11. Inert ong++ 11.4, ready to activate ong++ 9.4.- Code style avoids C++17 library corners known to be flaky on
g++ 9.4: noconstexpr std::optionalpast the trivially-constructible case; no<charconv>for floats; care aroundstd::string_viewimplicit conversions.
Consequences.
- Simpler code today: no OpenSSL 1.1.1 branch to maintain, no
g++ 9.4workarounds. - Fielded units on JetPack 5.x / Ubuntu 20.04 cannot run this binary until they upgrade JetPack.
- The shim architecture stays, so future 20.04 support is a small addition, not a rewrite. Estimated cost when triggered: 1-2 days for the OpenSSL shim, half a day for CI matrix, half a day for portability sweep. Total roughly a week including verification on real hardware.
Revisit trigger. Any of the following reopens this decision:
- Toborlife commits to supporting Go2 (or another quadruped) whose fielded units are on JetPack 5.x / Ubuntu 20.04.
- A customer requires
toboron Ubuntu 20.04 as a contract term. - More than five robots on Ubuntu 20.04 join the fleet.
When triggered:
- Add the OpenSSL 1.1.1 branch to
src/compat/openssl_compat.hpp. - Activate the
-lstdc++fslink condition in CMake. - Sweep the codebase for
g++ 9.4-incompatible C++17 library usage. - Add Ubuntu 20.04 aarch64 and x86_64 jobs to GitHub Actions CI.
- Verify on a real Ubuntu 20.04 robot before declaring the retrofit complete.
D-0002: Build-tool baseline is CMake 3.16, distinct from the runtime target¶
Date: 2026-08-27 Author: Ambarish Status: accepted
Context.
D-0001 fixes the runtime OS baseline at Ubuntu 22.04, but its context also noted CMake 3.16 vs 3.22 as a consequence,
and ../CMakeLists.txt was written with cmake_minimum_required(VERSION 3.16). This left an unresolved gap: is the
build-tool floor 3.16 or 3.22? That is a separate question from the runtime OS - what CMake version is needed to
configure the project, versus what OS the binary runs on.
Alternatives considered.
- CMake 3.22 floor (Ubuntu 22.04 default). Matches the runtime baseline exactly, one less number to track. Costs the cheap-20.04-retrofit posture: Ubuntu 20.04 ships CMake 3.16, so a 3.22 floor would need bumping before any 20.04 work.
- CMake 3.16 floor (Ubuntu 20.04 default). Keeps the retrofit door open at no present cost - nothing in the current
CMakeLists.txtneeds a feature newer than 3.16. Slight cost: one more version boundary to remember.
Choice. CMake 3.16 as the build-tool floor, explicitly decoupled from the Ubuntu 22.04 runtime target of D-0001. The floor tracks the oldest OS we want to be able to build on (20.04), the same "retrofit-ready without paying now" posture D-0001 established for the runtime.
Consequences.
../CMakeLists.txtstays atcmake_minimum_required(VERSION 3.16).- Any CMake feature newer than 3.16 must be guarded or avoided.
- The build-tool floor and the runtime target now move independently.
Revisit trigger.
- A CMake feature we need lands after 3.16 and cannot be worked around.
- The Ubuntu 20.04 retrofit (D-0001 revisit) is formally dropped, at which point the floor can rise to 3.22.
D-0003: HTTP client for Mode A is libcurl (system, OpenSSL flavour)¶
Date: 2026-08-27 Author: Ambarish Status: accepted
Context.
Mode A talks to the cloud over HTTPS (the register exchange and heartbeats). C++ has no standard HTTP client, so tobor
needs a library. It must build on x86_64 dev and run on aarch64 Jetson (Ubuntu 22.04), do HTTPS, and stay lean on the
robot. This choice is the first concrete piece of the "how do messages reach the cloud" transport seam; it is meant to
sit behind that seam, not leak across the codebase.
Alternatives considered.
- libcurl. Ubiquitous C library, already present as a runtime on the G1 (
libcurl4, OpenSSL flavour, 7.81.0). Does HTTPS via OpenSSL. Tiny, battle-tested, one system dependency, nothing vendored. Cost: C-style API with a write-callback for response bodies - a little ceremony. - cpp-httplib. Single-header, clean modern-C++ API, much simpler call site. Cost: header-only (slower builds), one more third-party file in the tree, smaller provenance than curl.
Choice. libcurl. It is what production robot/embedded software uses for this job, it is already on the target runtime (so deployment adds nothing), it matches the dev laptop's system libcurl exactly (7.81.0 / OpenSSL 3.0.2), and it keeps the build lean. The callback ceremony stays contained behind the transport seam.
Consequences.
- Build depends on
libcurl4-openssl-dev(OpenSSL flavour, to match the robot). ../CMakeLists.txtusesfind_package(CURL REQUIRED)+target_link_libraries(tobor PRIVATE CURL::libcurl).- Must build against the system libcurl, not a Conda-provided copy - see the Build section note in
../README.md. A Conda-linked binary would fail on the robot, which has no Conda. - HTTPS/TLS specifics defer to the OpenSSL compat posture in D-0001.
Revisit trigger.
- Mode B (MQTT + mutual TLS) supersedes HTTP transport; libcurl's role shrinks to Mode A only.
- libcurl proves unavailable or too heavy on a future target.
D-0004: Header include guards use #pragma once¶
Date: 2026-08-27 Author: Ambarish Status: accepted
Context.
Every header must be protected against being included more than once in a translation unit. Two mechanisms exist: the
standard #ifndef/#define/#endif include guard, and #pragma once. tobor's build targets are a closed, known set -
x86_64 dev laptops, GitHub Actions CI runners (x86_64 Linux), and aarch64 Jetson devices (Ubuntu 22.04) - all using gcc
or clang.
Alternatives considered.
#ifndefinclude guards. Standard C++, guaranteed on every conceivable compiler, zero edge cases. Cost: three lines per header plus a unique guard macro name that, if mis-copied, causes a silent double-inclusion collision.#pragma once. One line, no guard name to get wrong. Technically a compiler extension (though near-universal), with a rare edge case when the same header is reachable via two different filesystem paths. Fully supported by gcc and clang - i.e. on all of tobor's actual targets.
Choice.
#pragma once. tobor's compiler set is closed and known (gcc/clang on x86_64 and aarch64), and all of them support it.
Universal-compiler portability - the main advantage of #ifndef guards - buys tobor nothing, since it will never be
built on a compiler that lacks #pragma once. In exchange we get shorter headers and eliminate the guard-name collision
footgun.
Consequences.
- Every header begins with
#pragma onceas its first line after the file comment. - The build depends on compiler support for
#pragma once; acceptable given the fixed gcc/clang toolchain. - The "same file via two paths" edge case is not a concern for tobor's normal source layout.
Revisit trigger.
- A target compiler is added that does not support
#pragma once. - A build setup is introduced where a header becomes reachable through multiple paths (heavy symlinking, unusual vendoring), making the edge case real.
D-0005: Cloud location comes from runtime config, not the binary¶
Date: 2026-08-28 Author: Ambarish Status: accepted
Context.
The cloud address differs by environment - the local mock cloud in dev, the real cloud in production - and must be switchable without recompiling. Endpoint paths (/health, and later /register, /heartbeat) are different: they are fixed by the cloud's API contract, not by environment.
Alternatives considered.
- Hardcode the URL in the source. Simple, but forces a recompile per environment and bakes a dev address into a production binary.
- Read the URL from runtime configuration. One binary serves every environment; the address is chosen at startup.
Choice.
The cloud base URL comes from runtime configuration. As an interim, cmd_run reads it from the TOBOR_CLOUD_URL environment variable, with a single named dev default (http://127.0.0.1:8080, the mock cloud). A config file will supersede this at the register step. Endpoint paths stay as named constants in code, since they are part of the fixed cloud API, not environment config.
Consequences.
check_cloud_healthtakes the base URL as a parameter; nothing in the transport is hardcoded.- A single named default exists for dev convenience, overridable via the environment.
- The full config file (cloud URL plus token path) arrives with
registerand will layer over the env var.
Revisit trigger.
- The config file lands at the register step - the env var becomes an override, not the primary source.
- Production requires forbidding the dev default (a strict "must be configured or refuse to run" mode).
D-0006: A health check succeeds on HTTP 200 (body not inspected)¶
Date: 2026-08-28 Author: Ambarish Status: accepted
Context.
check_cloud_health must decide whether the cloud is healthy. libcurl reports transport success (CURLE_OK) even when the server answered with an HTTP error like 404 or 500, so the transport result alone is not enough to judge health.
Alternatives considered.
- Transport success only (
CURLE_OK). Wrong: it would call a 404 or 500 reply "healthy." - Require HTTP 200. The standard health-check convention; does not inspect the body.
- Require HTTP 200 and an expected body. Strictest, but couples the client to an exact health-response format the cloud team has not defined yet.
Choice.
Success means the request completed (CURLE_OK) and the HTTP status is 200. The body is not inspected. This is the standard convention and avoids hardcoding a response format that does not exist yet.
Consequences.
- A non-200 reply is reported as a failure, even though libcurl's transport "succeeded."
- A 200 with an empty or unexpected body still counts as healthy (a rare broken-server case, as we saw when the mock cloud crashed mid-response).
Revisit trigger.
- The real cloud defines a health-response worth verifying (a specific body or JSON), at which point a body check is added.
D-0007: Registration token is provided by CLI argument or interactive prompt¶
Date: 2026-08-29 Author: Ambarish Status: accepted
Context.
register needs the per-robot token to exchange for a credential. It runs once, at setup, and the operator may not be technical - so the input method must be easy and hard to get wrong. The handoff's original plan was to read the token from a file whose path comes from config.
Alternatives considered.
- CLI argument only (
tobor register "<token>"). Simplest to script. But a bare argument asks the operator to know the exact syntax and quoting, and a secret on the command line is visible inpsand shell history. - Interactive prompt only. Easiest to instruct ("run register, paste when asked"), and keeps the token out of
ps/history. Not scriptable. - Token file (path from config), per the handoff. Most secure and automation-friendly, no leak. But heavier setup for a one-time manual step by a non-technical operator.
Choice.
Accept the token as a CLI argument if given, otherwise prompt for it interactively. This serves the non-technical operator first (run register, paste at the prompt - no syntax to get wrong, and no secret in ps/history) while keeping a scriptable argument path. The "credential source" is a single isolated step, so a --token-file option can be added later without disturbing the rest.
Consequences.
- The token can appear in
ps/shell history when passed as an argument; acceptable because it is single-use at one-time setup, and the interactive path (recommended for operators) avoids it. - Diverges from the handoff's "token in a file" plan; per the docs-change-first rule, this entry records the change.
- An empty token (prompted, nothing entered) is a usage error (exit 2).
Revisit trigger.
- Production or automation needs a non-interactive, non-argument token source → add
--token-file(or a config-file path). - A security review requires that the token never touch argv → drop the argument path, prompt/file only.
D-0008: Transport functions return a result; commands interpret it¶
Date: 2026-08-29 Author: Ambarish Status: accepted
Context.
The first transport function, check_cloud_health, did everything: made the request, judged success (the 200 check), printed the outcome, and returned an exit code. Adding register_robot - which must return the credential body to its caller so it can be saved - exposed that this mixes transport, policy, and presentation in one function.
Alternatives considered.
- Transport does everything (judge + print + exit code), as
check_cloud_healthoriginally did. Simple for one function, but the transport can't hand back data the caller needs (the credential), and it owns presentation that belongs to the command layer. - Transport returns a raw result; the command interprets and presents. Clean separation:
http_clientmoves bytes and reportsok/status/body;cmd_run/cmd_registerdecide what it means and what to say.
Choice.
Transport functions return an HttpResult (ok, status, body) and nothing more; the commands do the judging and all user-facing messages. check_cloud_health was refactored to this shape to match register_robot.
Consequences.
http_clientis pure transport; libcurl and result-shaping stay inside it, and callers see onlyHttpResult.- Commands own presentation and exit codes, so one transport result can be presented differently per command.
- Low-level transport errors (can't init/connect) are still printed inside
http_clientas diagnostics; HTTP-level interpretation (200 vs 401) is the command's job.
Revisit trigger.
- Mode B's transport (MQTT) is added behind an interface;
HttpResultgeneralizes or is replaced by a transport-neutral result type at that seam.
D-0009: JSON parser for Mode A is nlohmann/json, vendored as a single header¶
Date: 2026-08-31 Author: Ambarish Status: accepted
Context.
cmd_register receives the cloud's /register reply as a JSON body ({"credential": "..."}) and must pull the credential out of it. C++ has no standard JSON parser, so tobor needs a library. Same constraints as the libcurl choice (D-0003): it must build on x86_64 dev and run on aarch64 Jetson (Ubuntu 22.04), and it must stay lean on the robot. The standing rule is to minimize dependencies and prefer self-contained / vendored over anything installed.
Alternatives considered.
- nlohmann/json, vendored single header. The whole library is one
json.hpp(~950 KB). Drop it ininclude/nlohmann/,#include <nlohmann/json.hpp>, done - header-only, so nofind_package, no link line, no package to install on any dev machine or on the robot. Clean, modern C++ API. Cost: a ~950 KB file lives in the tree, and the one translation unit that includes it compiles slower. - nlohmann/json via system package (
nlohmann-json3-dev+find_package(nlohmann_json)). Nothing vendored in the tree. Cost: adds a build-time dependency to provision on every dev machine and in CI, against the "prefer vendored over installed" posture; the robot image would need it too. - A different parser (RapidJSON) or a hand-rolled reader. RapidJSON is fast but a heavier API for a one-field parse; hand-rolling is fragile and pointless for a solved problem.
Choice.
nlohmann/json, vendored as the single include/nlohmann/json.hpp (3.12.0). It matches the minimize-dependencies / prefer-vendored rule exactly: one file in the tree, no install step anywhere, and no CMake change beyond the include/ directory that was already on the include path. The API is the most readable of the options for the small job we have.
Consequences.
include/nlohmann/json.hpp(pinned at 3.12.0) lives in the repository; updating it is a manual file swap.CMakeLists.txtneeds nothing for it - nofind_package(nlohmann_json), notarget_link_librariesentry. Header-only means the include path (already present) is enough.- The translation unit that includes it (
commands.cpp) compiles more slowly, since the whole library is compiled in. - nlohmann/json throws on malformed input or a missing key; the caller (
cmd_register) wraps the parse intry/catchand turns any failure into a plain-language message, per the D-0008 transport/command split.
Revisit trigger.
- The header's compile-time cost becomes painful once many translation units include it → move to the system package or a precompiled form.
- A future need (ABI stability, a feature only the packaged build tracks) outweighs the convenience of vendoring → switch to
find_package. - Mode B moves the wire format off JSON, at which point this parser's role shrinks or disappears.
D-0010: Credential is stored as a plaintext file, owner-only, written atomically¶
Date: 2026-08-31 Author: Ambarish Status: accepted
Context.
register exchanges the token for a persistent credential, and run - plus an on-robot Docker container that makes REST calls - must read it back later. So the credential has to be persisted on disk. Three questions had to be answered together: where it lives, how it is protected at rest, and how it is written so a crash cannot destroy it. Constraints: the register step must stay easy for a non-technical operator (no per-run sudo); the robot runs everything as the unitree login user (uid 1000), and the container runs as the same uid; the robot can lose power at any instant; and the standing rule is to minimize moving parts for a Mode A stopgap.
Alternatives considered.
- Location.
/etc/tobor(admin-edited config, wrong bucket for program-generated state);/var/lib/tobor(the FHS home for program-managed state - correct, but root-owned, so needs a one-time setup); a home path under~/.local/state(no root, but buried and awkward to bind-mount into a container);tmpfs(RAM-only, never on disk, but lost on reboot sorunwould have to re-register every boot). - Protection at rest. File permissions only (plaintext +
0600); app-level encryption (moves the secret to a key that, if stored on the same disk, an attacker reads instead - security theater; the real form needs a hardware-backed key, which is Mode B); full-disk encryption (an ops/deployment lever, not client code). - Write method. Write straight to the target file (simple, but a crash mid-write leaves a truncated file - old credential destroyed, new one incomplete); write to a temp file then
renameinto place (atomic - the target is always either the whole old file or the whole new one).
Choice.
A plaintext file at /var/lib/tobor/credential (overridable via TOBOR_CREDENTIAL_PATH, per D-0005), owner-only (0600 file inside a 0700 directory), written atomically (.tmp sibling, then rename). Protection is by permission, not encryption. Because the file is owned by uid 1000 and both tobor and the container run as uid 1000, each reads it as the owner; every other user is locked out, and the 0700 directory hides its existence from them entirely. The container reads it by bind-mounting the folder read-only (-v /var/lib/tobor:/etc/tobor:ro) - the folder, not the single file, so the atomic-rename replacement on re-register is always seen. The credential is never printed or logged. register writes only after a successful 200, so a failed or wrong-token re-registration cannot clobber a good credential.
Consequences.
- One-time root at setup (
mkdir /var/lib/tobor+chown unitree); after thatregisterneeds nosudo. Dev on x86 setsTOBOR_CREDENTIAL_PATHto a home path and needs no root at all. - Re-registration is safe and idempotent: the atomic rename replaces the credential in one step, with no half-written window.
- The secret exists only as the
0600file and in process memory - not in logs,docker inspect, or terminal output. - Accepted residual exposures:
rootcan read the file (true of any plaintext secret on the box); a backup of/var/libcarries the credential in plaintext (backup hygiene is required); and the.tmpfile exists briefly at default permissions during the write before it is tightened to0600(negligible on a single-user robot). - The on-disk format is now a compatibility contract - per the versioning table, changing it is a MAJOR bump.
Revisit trigger.
- Physical theft of a unit, or backups leaving the trust boundary, become real threats → add full-disk encryption (ops), not per-file encryption.
- A requirement to protect the credential from
rootor from disk theft at the application layer → hardware-backed key storage (Jetson secure element / TPM) and/or short-lived tokens minted in memory - the Mode B direction. - The
systemdunit runsrunas a dedicated system user or root rather than theunitreelogin user → reconsider ownership and whether the home-path dev override still fits. - The config file lands (D-0005 / D-0007) → the credential path moves into config, and
TOBOR_CREDENTIAL_PATHbecomes an override rather than the primary source.
D-0011: Registration sends device identifiers; the hardware serial is the anchor¶
Date: 2026-09-01 Author: Ambarish Status: accepted
Context.
The cloud needs to identify each robot at register time, to vet it before issuing a credential, and to key an inventory record (OS, board, etc.) on a stable ID. So register must send identifying information alongside the token. The token authenticates; it does not identify the physical unit. Three questions had to be settled: what to send, which field is the stable anchor, and how much parsing the binary should do versus the backend.
Alternatives considered.
- Anchor identifier. MAC address - drifts badly (the G1 showed ~8 interfaces: real NICs, virtual bridges, USB gadgets, all mixed), so "which MAC" has no stable answer.
/etc/machine-id- stable per OS install but regenerated on every re-flash, so it is an install marker, not a permanent identity. Hardware serial from device-tree (/proc/device-tree/serial-number) - burned into the module, survives re-flash; verified populated on the G1 (1424325095622) and cross-confirmed by the DMIproduct_serial. - OS details. Parse
PRETTY_NAME(and strip quotes) inside the binary; or send the raw/etc/os-releaseand parse server-side. - Wire format. Keep the bare-token body and bolt on a field; or move the body to a JSON object.
Choice.
Send a small JSON object: {"token": ..., "device": {serial, machine_id, os_release, model}}. The hardware serial (device-tree) is the permanent anchor the cloud keys on; machine_id rides along as a re-flash marker (same serial + new machine-id means the robot was re-imaged); os_release is sent raw for the backend to parse; model is metadata. Every field is read defensively - a missing file yields an empty string, not an error - so one binary runs unchanged on the aarch64 robot (device-tree present) and the x86 dev box (device-tree absent). The serial is overridable via TOBOR_DEVICE_SERIAL for dev boxes with no device-tree. device is a JSON object so fields can be added later without breaking the contract.
Consequences.
- Breaking change to the
/registerrequest contract (bare token → JSON), coordinated with the cloud team. Pre-1.0, this is a minor-version bump (0.1.4 → 0.2.0). The response (a JSONcredential) is unchanged. - The token remains the security check; the identifiers are metadata and binding, not authentication - they are spoofable, so the cloud must not treat them as proof of anything.
- Parsing
os_releaselives on the backend, so a parsing fix ships server-side without reflashing the fleet - the reason the binary sends raw text. - The hardware serial identifies the Jetson module, so it changes if the compute module is swapped. The Unitree chassis serial would survive that, if it can be read programmatically.
- Dev boxes have no hardware serial and must set
TOBOR_DEVICE_SERIAL, or the cloud rejects the empty serial.
Revisit trigger.
- The Unitree chassis serial becomes readable programmatically → prefer it (or send it alongside) as the true lifelong robot identity.
- Mode B defines a richer enrollment payload → this
deviceblock generalizes or is replaced. - A field needs server-side structure the raw
os_releasecannot give cheaply → parse it in the binary after all, now that the seam exists.
D-0012: register retries transient failures with bounded exponential backoff¶
Date: 2026-09-01 Author: Ambarish Status: accepted
Context.
register runs once, at setup, often over a freshly-configured network and often driven by a non-technical operator. A single transient hiccup - the network not up yet, a DNS blip, the cloud momentarily returning a 5xx - would fail the whole command and force the operator to run it again by hand. The transport (register_robot) is deliberately one-shot and protocol-neutral (D-0008), so any retry policy has to live above it, in the command layer, without pushing policy back down into the transport.
Alternatives considered.
- No retry (status quo). Simplest; the operator re-runs
registeron any blip. Poor fit for a one-time manual step by a non-technical operator, where a two-second network lag becomes a support call. - Retry every failure, including 4xx/401. A wrong token or a malformed request will never succeed on a repeat, so retrying it just hammers the cloud and makes the operator wait several seconds for an answer we already had.
- Retry only transient outcomes, with a small capped exponential backoff. Retry a transport failure or a 5xx; stop at once on any settled answer. Bounded attempts and growing delays give the network a moment to recover without hanging.
- Retry inside the transport. Would bury retry policy and its user-facing messages inside
http_client, breaking the D-0008 split. Rejected in favour of a loop incmd_register. - Add randomized jitter to the delays. Spreads retries when many clients back off together. Unnecessary for a single robot registering by hand; deferred until registration is automated or fleet-scale.
Choice.
A retry loop in the command layer (cmd_register) wraps the one-shot register_robot. It retries only transient outcomes - a transport failure (HttpResult.ok == false, i.e. could not connect or timed out) and a 5xx server error - and stops immediately on every terminal outcome: a 200 success, a 401 wrong-token, any other 4xx, and our own credential-save failure. The split is a small is_retryable(HttpResult) helper. The default policy is 4 attempts total (first try plus 3 retries) with delays of 1s, 2s, 4s (base 1s, doubling), about 7 seconds worst case before giving up with the friendly failure message; each retry prints a visible retrying (n/N) in Xs... line first. Both numbers are overridable from the environment within bounds that fall back to the default on anything out of range: TOBOR_REGISTER_MAX_ATTEMPTS (1 to 10, default 4) and TOBOR_REGISTER_RETRY_DELAY (0 to 60 seconds, default 1). Retrying register is safe because the cloud keys the device on its hardware serial (D-0011), so a repeated call updates the same record rather than creating a duplicate.
Consequences.
- The transport stays one-shot and protocol-neutral; the retry loop, its classification, and its messages live entirely in
cmd_register, preserving the D-0008 layering. - A transient blip at setup now self-heals within about 7 seconds instead of requiring the operator to notice and re-run.
- Terminal outcomes (401, other 4xx, a save failure) fail fast, with no wasted attempts and no delay.
- Two new env knobs exist. They are range-checked, so a typo or an absurd value quietly falls back to the default; the upper bound on attempts also keeps the doubling delay from overflowing an
int. - Retry uses blocking sleeps (
std::this_thread::sleep_for). Acceptable for a single-shot manual command; it would not suit the always-onrunpath, which is not covered by this decision. - No jitter yet, which is fine for one robot at manual setup and is recorded here as a known limitation.
- Retry safety rests on registration being idempotent on the serial (D-0011); if that ever stops being true, retry could create duplicates and must be re-evaluated.
- The retry delay doubles on each attempt and is not capped beyond the per-value bounds, so the total wait scales with both knobs: the default is about 7 seconds, but a 60-second base over 10 attempts runs to hours. An operator cranking the knobs should expect this.
Revisit trigger.
- Registration becomes automated or fleet-scale (many robots registering at once) → add jitter so retries do not synchronize into a thundering herd.
- The cloud stops being idempotent on the hardware serial → repeated registers could create duplicate records; re-evaluate whether retry is still safe.
- Retry is wanted on the
run/ heartbeat path too → lift the loop out ofcmd_registerinto a shared helper, with non-blocking waits. - Mode B (MQTT + mutual TLS) replaces the HTTP transport → retry/backoff moves to that transport's reconnect semantics.
D-0013: Mode A transport uses HTTPS with certificate verification always on¶
Date: 2026-09-01 Author: Ambarish Status: accepted
Context.
Mode A sends a token to the cloud and receives a credential in return. On plain HTTP both travel in cleartext, so anyone on the path can read the token or the issued credential. The transport is libcurl, which already performs TLS when the URL scheme is https, so the work is less about writing encryption and more about turning it on deliberately, keeping it secure by default, and making it testable locally where there is no public certificate authority to sign a cert for 127.0.0.1.
Alternatives considered.
- Stay on plain HTTP. Simplest, but ships the token and credential in cleartext. Unacceptable for anything past the local mock.
- HTTPS, URL-driven, verification always on.
https://gets TLS with full verification,http://stays plain. libcurl derives this from the URL scheme, so almost no code. Testing a self-signed cert needs a custom trust anchor. - HTTPS with an option to disable verification for testing. Convenient locally, but it puts a dangerous "trust anything" switch in the shipped binary, and someone will use it in production. Rejected outright.
- Strict HTTPS-only mode (refuse
http://). Safest posture, but it breaks the local plain mock and current dev flow. Deferred until production needs it.
Choice.
URL-driven TLS: an https:// cloud URL gets an encrypted, verified connection; http:// stays plain. Certificate verification is always on and set explicitly (CURLOPT_SSL_VERIFYPEER and CURLOPT_SSL_VERIFYHOST); the binary has no switch to disable it. An optional trust anchor is exposed as TOBOR_CA_BUNDLE (mapped to CURLOPT_CAINFO): unset, tobor uses the system CA bundle (correct for the real cloud, whose cert a public authority signs); set, it trusts that file instead (a self-signed cert in dev, or a private CA on the robot if ever needed). The CA path is read in the transport, since TLS trust is a transport concern (D-0008). tobor presents no client certificate; this is one-way TLS, like a browser visiting a website. A TLS trust failure - the cloud's certificate cannot be verified (CURLE_PEER_FAILED_VERIFICATION), or the TOBOR_CA_BUNDLE file cannot be read (CURLE_SSL_CACERT_BADFILE) - is surfaced as a distinct terminal outcome (HttpResult.tls_verify_failed): the register retry loop does not retry it, since neither will fix itself, and both register and run report that the cloud's TLS certificate could not be verified, rather than "couldn't reach the cloud".
Consequences.
- The token and credential now travel encrypted on an
https://URL. Against the real cloud, tobor needs no configuration, using the system CA bundle. - The robot needs
ca-certificatesinstalled so the system trust store is present. - A bad or untrusted certificate fails fast at one attempt with an accurate message, instead of retrying four times with a misleading one.
- Two TLS errors are treated as terminal (not retried):
CURLE_PEER_FAILED_VERIFICATION(certificate not trusted) andCURLE_SSL_CACERT_BADFILE(the CA bundle could not be read, usually a badTOBOR_CA_BUNDLEpath). Other TLS handshake errors stay in the generic, retryable transport-failure bucket. Acceptable now; extendable if a real case needs it. - No insecure or skip-verify switch exists, by design. Local testing uses a trusted self-signed cert supplied through
TOBOR_CA_BUNDLE, not by turning verification off. - Dev and CI need a self-signed cert and an HTTPS-capable mock. Both are dev-only; the cert and key are git-ignored and regenerated from a documented command.
- No strict "must be HTTPS" mode yet; an
http://URL still works, which the local plain mock and current dev rely on. - Client authentication is still by token only; mutual TLS (a client certificate) is a Mode B concern, not this decision.
Revisit trigger.
- Production requires refusing plain
http://→ add a strict HTTPS-only mode that fails closed. - The robot must trust a private or internal CA → set
TOBOR_CA_BUNDLEon the robot (already supported) and document the bundle's location. - A real deployment hits a non-verification TLS error that should also be terminal → extend the classification beyond
CURLE_PEER_FAILED_VERIFICATION. - Mode B (mutual TLS over MQTT) arrives → a client certificate and the MQTT transport supersede this one-way HTTPS.
D-0014: CI builds the aarch64 target on a native GitHub-hosted arm64 runner¶
Date: 2026-09-02 Author: Ambarish Status: accepted
Context. The robot ships aarch64 on Ubuntu 22.04, but development is on x86. Nothing automatically verified that the shipped target still builds, and the README even claimed CI existed when it did not. We wanted CI that builds the binary the robot actually runs, on every change to main, cheaply.
Alternatives considered.
- No CI (status quo). Relies on remembering to cross-build locally. A break can reach main unnoticed, and the README's CI claim stays false.
- QEMU emulation on a standard x86 runner (Docker + the existing Dockerfile). Works on any plan at the cheapest runner rate, but slow (emulated
aptinstall about 80s) and one more layer to maintain in CI. - Native aarch64 runner (
ubuntu-22.04-arm). As of 2026-01-29 these are available in private repos and draw from the plan's included free minutes. Native speed, no emulation, and the 22.04 image matches the robot's OS exactly.
Choice.
A single workflow (.github/workflows/aarch64-build.yml) builds the Release aarch64 binary on a native ubuntu-22.04-arm runner. It runs only on push to main (i.e. on PR merge) plus a manual trigger, with concurrency: cancel-in-progress so a superseded run is not billed. It installs the same deps as the Dockerfile (build-essential, cmake, libcurl4-openssl-dev) and finishes with a --version smoke test. The 22.04 arm image is chosen over 24.04 so the toolchain (gcc 11.4, libcurl 7.81.0, OpenSSL 3.0) matches the robot (D-0001, D-0003).
Consequences.
- Every merge to main is verified to build for the robot's architecture, natively, in about a minute, within the free-minute allocation.
- Builds run only on merge, not on PRs, to keep cost minimal; the trade-off is that a break is caught just after it lands on main, not before. Local cross-builds stay the pre-merge check.
- CI no longer uses Docker or QEMU; the Dockerfile stays for local cross-builds (README "Cross-building for aarch64") and as the emulation fallback in git history if native arm runners ever go away.
- Only aarch64 is built in CI; the x86 build is a local dev convenience and is not gated.
Revisit trigger.
ubuntu-22.04-armis deprecated or retired (GitHub flagged ~2026-09-17) → switch the runner image, or fall back to the QEMU + Docker path (still in the Dockerfile and git history), which pins 22.04 via the Docker image independent of GitHub's runners.- Builds need to gate PRs (catch breaks before merge) → add
pull_requestto the triggers, accepting the extra minutes. - Automated tests exist → add a test job so CI does more than compile.
- The Ubuntu baseline moves off 22.04 (D-0001 revisit) → bump the runner image to match.
D-0015: Releases are cut by a version tag and publish an aarch64 binary¶
Date: 2026-09-02 Author: Ambarish Status: accepted
Context. CI verifies that every merge builds, but the built binary is discarded. The cloud team needs the actual aarch64 binary to load into their backend for distribution. We wanted a deliberate, versioned way to produce and hand off a built binary, without publishing one on every merge.
Alternatives considered.
- Workflow artifacts on every build. Downloadable from the run page, but per-run, expiring, reachable only through the Actions API, and unversioned. Fine as a dev convenience, poor as a cross-team feed.
- A release on every merge to main. Automatic, but produces a pile of unnamed releases and could ship work in progress.
- A release cut by a version tag. Deliberate, versioned, pinned to an exact commit; the standard way to hand a build to another team.
Choice.
A separate workflow (.github/workflows/release.yml) fires on a version tag (v*.*.*). It builds the Release aarch64 binary on ubuntu-22.04-arm (same as CI), re-runs the smoke test and arch check, guards that the tag matches the binary's version, packages it as tobor-<version>-aarch64.tar.gz (the binary named tobor inside) with a SHA-256 checksum, and publishes a GitHub Release with those assets. The workflow is granted only contents: write.
Consequences.
- Cutting a release is a deliberate act (
git tag vX.Y.Z && git push origin vX.Y.Z); nothing publishes on a plain merge. - Each release is versioned, pinned to a commit, and carries a checksummed tarball the cloud team fetches (with a token, since the repo is private) and loads into their backend.
- The tag-matches-version guard prevents shipping a mislabeled binary; a forgotten
CMakeListsbump fails the release. - Only aarch64 is published (the robot's arch); the x86 build stays a local dev convenience.
- This is release tooling and does not change the shipped binary, so the version stays 0.4.0.
- Releases build on the same
ubuntu-22.04-armrunner as CI (D-0014), so the same deprecation revisit applies.
Revisit trigger.
- The cloud team needs anonymous, tokenless download → they mirror the asset into their own public backend, or the repo goes public.
- More target architectures ship → the release build gains a matrix.
- Release notes need curating rather than auto-generation → replace
generate_release_noteswith a CHANGELOG-derived body. ubuntu-22.04-armretires (D-0014) → the release build switches runner image or falls back to QEMU + Docker, same as CI.
D-0016: run becomes check, which also validates the credential; serve is reserved¶
Date: 2026-09-08 Author: Ambarish Status: accepted
Context.
run only ever did a one-shot /health probe, but its name promised the always-on online signal it never performed. Two distinct needs had become clear: a one-shot operator diagnostic (is the cloud reachable, and is this robot's stored credential still accepted), and, later, a long-lived process that keeps the robot online. Naming both run was the source of the confusion.
Alternatives considered.
- Keep
runas-is. No churn, but the name keeps overpromising, and there is no clean name left for the future daemon. - Rename
runtocheckfor the one-shot, reserveservefor the daemon. Honest names, at the cost of a CLI-breaking rename in a shipped (0.4.0) command. - One command that does both (probe now, loop with a flag). Fewer names, but conflates a quick diagnostic with a service and complicates both.
Choice.
Rename run to check, and make check do two probes: cloud reachability via GET /health, then credential validity via a POST /heartbeat presenting the stored credential as an Authorization: Bearer token (D-0013 transport, D-0008 seam). serve is reserved, unbuilt, for the future always-on signal. An unregistered robot, or one whose credential is rejected, fails check with a non-zero exit, since it is not fully operational.
Consequences.
runno longer exists (unknown command); a CLI-contract break, which pre-1.0 is a MINOR bump. Docs and examples move tocheck.checknow reports two lines and can fail for a new reason (no or rejected credential), which is a behavior change from the old pure connectivity check.- The one-shot heartbeat is the exact call the future
serveloop will reuse, so this is a down payment on it, not throwaway work. - No back-compat
runalias is kept; the command was only in 0.4.0 and the docs are updated in the same change.
Revisit trigger.
serveis built and its loop liftssend_heartbeatinto a scheduled, jittered call (see D-0012's fleet-scale note).- Operators want a reachability-only check that does not touch the credential, at which point the two probes split into separate commands or a flag.
- The cloud pins which non-200 statuses mean "credential rejected"; today only
401maps to rejected, and any other non-200 is reported as an unexpected status.
D-0017: CLI dispatch is a command table with a uniform command signature¶
Date: 2026-09-08 Author: Ambarish Status: accepted
Context.
Dispatch was an if-chain in main, and the help text was a separate hardcoded block. The two drifted: after the run to check rename the help still described the old command. The command set is expected to grow past a handful (serve, status, decommission), so the scattered form would keep drifting and multiplying.
Alternatives considered.
- Keep the
if-chain and hardcoded help. Fine for two commands, but name, help, and dispatch live in three places and drift, as they already did. - A small in-file command table. One row per command (name, usage hint, one-line help, handler); dispatch loops it and help is generated from it, so they cannot fall out of sync. Costs a uniform handler signature.
- A CLI-parsing library (CLI11 and similar). Real subcommand and flag handling for free, at the cost of a dependency, against the minimize-dependencies rule (D-0003, D-0009) while the needs are this small.
Choice.
A static command table in main.cpp. Every command has the signature int(const CommandArgs&) (the arguments after its name), so the table can hold each handler directly and each command parses what it needs. --help is generated by walking the table; dispatch matches the typed name against it. No new dependency.
Consequences.
- Adding a command is one new row; help and dispatch update together, so the drift that bit the rename cannot recur.
- Commands own their own argument parsing (
registerreads the first arg as the token;checkignores args). Extra arguments are currently ignored silently. - A parsing library is deferred until real flags and options justify it; at that point this table is the natural place it plugs in.
Revisit trigger.
- Commands gain real flags/options, or the count reaches dozens, so hand-rolled parsing stops paying, adopt a parser library.
- Commands grow large enough that they should live in their own files rather than one
commands.cpp.
D-0018: Transport and credential loading report typed outcomes¶
Date: 2026-09-08 Author: Ambarish Status: accepted
Context.
Interpreting an HttpResult (TLS failure, unreachable, bad status) was duplicated as an if-ladder in each command. Separately, load_credential returned std::optional<std::string>, which can only say "value or nothing", so a missing file, an unreadable file, and an empty file all collapsed into one "not registered" message, a wrong diagnosis for the last two on a real robot.
Alternatives considered.
- Keep per-command
if-ladders andoptional. No new types, but the interpretation logic is copied per command and "why did the load fail" cannot be expressed. - Named outcome types. An
HttpOutcomeenum with aclassify(HttpResult)that interprets a response once; aCredentialResult(aCredentialStatusplus the text) that says how a load went. Costs a little type machinery.
Choice.
Introduce HttpOutcome plus classify, so every command interprets a response through one function and switches on the named result, and CredentialResult/CredentialStatus (Loaded, Missing, Unreadable, Empty) so check reports an accurate cause. Both keep the D-0008 seam: the transport and store report a typed fact, the commands own the messages and exit codes. The switches list every case with no default, so -Wswitch (a Release error) forces a new outcome to be handled everywhere.
Consequences.
- One place to interpret an HTTP result and one place to classify a credential load; the shared transport-failure wording lives in a single helper.
- Operators get a real diagnosis: a permissions problem or a directory path says so, distinct from "not registered".
- A little more type surface (two enums, one struct) than the
optionalit replaced.
Revisit trigger.
- An outcome needs to carry more than a label (a retry hint, a structured error body), the struct grows or gains a variant.
- Mode B's transport is added behind the seam (D-0008 revisit),
HttpOutcomegeneralizes or is replaced by a transport-neutral outcome.
D-0019: unregister removes the stored credential, with a best-effort cloud revoke¶
Date: 2026-09-08 Author: Ambarish Status: accepted
Context. A registered robot keeps its credential in a plaintext file (D-0010). Nothing removed it, so a robot being reset, resold, or decommissioned kept a usable bearer credential on disk, and the cloud kept treating that credential as valid. Two gaps: the local file, and the cloud's record of the credential.
Alternatives considered.
- Local delete only. Removes the file from this device, easy and works offline, but the credential stays valid at the cloud, so a copy taken before the wipe still works.
- Cloud revoke required before delete. Makes the credential dead everywhere, but ties the wipe to cloud reachability, so a robot already pulled off the network (the common decommission case) cannot be wiped.
- Local delete always, plus a best-effort cloud revoke. Wipes the device unconditionally and asks the cloud to revoke as well, without letting a cloud problem block the wipe.
Choice.
unregisterdeletes the local credential unconditionally, and first makes a best-effort call to the cloud to revoke it. Order is revoke, then delete. Flow: with no credential it is a friendly no-op (exit 0); otherwise it shows the exact path, warns the robot will have to register again, and asks for a typedyes(only an exactyesproceeds; anything else, or no input at all, aborts and leaves the file, exit non-zero).--forceskips the prompt for automation. After confirmation it POSTs to/unregisterwith the credential as a Bearer token: a200confirms the revoke; any other outcome (unreachable, TLS failure, or a401) is reported but does not stop the delete. A401is reported honestly, that the credential was not revoked by this call and may already be revoked or the cloud URL may be wrong, rather than implying success. The delete removes only the credential file and leaves/var/lib/tobor, so re-registering needs no root. Deletion lives incredential_store(remove_credential, with acredential_pathaccessor); the command owns the prompt, messages, and exit codes (D-0008).
Consequences.
- A decommission wipes the device even when the cloud is down or wrong, which is what a reset or resale needs.
- The cloud revoke is best-effort, so after an unconfirmed revoke the credential may still be accepted at the cloud until it is revoked or expires there. The message says so.
- Revoke-before-delete is also crash-safe: if interrupted after the revoke, the leftover file is a dead credential cleaned up next run; deleting first could strand the cloud copy as valid with no local credential left to authenticate a revoke.
- The exit code reflects the local delete (the guaranteed action), not the revoke.
- New cloud endpoint
POST /unregister(seeCLOUD_BACKEND.md). The mock implements it; the real cloud is the cloud team's to build. -
remove_credentialreturns true when the file is gone afterward (deleted or already absent), so the no-credential case is a clean success. Revisit trigger. -
Operators want a guarantee that nothing is deleted unless the cloud confirmed the revoke, add a strict mode (D-0020).
- The
/unregistercontract changes, updatesend_revokeand the messages to match. - Mode B replaces the bearer credential with a per-robot key, and decommission becomes key revocation at the CA rather than deleting a file.
D-0020: strict --require-revoke and an idempotent /unregister (planned)¶
Date: 2026-09-08 Author: Ambarish Status: proposed
Context.
D-0019 deletes the local credential even when the cloud does not confirm the revoke. For a routine reset that is correct, but it has two soft spots. A 401 from the cloud is ambiguous: it can mean "already revoked" or "you are pointed at the wrong cloud". D-0019 already reports that honestly, but the ambiguity itself remains, and an operator who wants a hard guarantee has no way to say "do not delete unless the cloud confirmed the revoke".
Alternatives considered.
- Leave D-0019 as is. Simple, but no strict guarantee, and the
401ambiguity stays. - Add
--require-revokeon top of today's401semantics. Gives a guarantee, but hits an idempotency trap: a second run (or a crash between revoke and delete) presents an already-revoked credential, the cloud answers401, and strict mode then refuses to ever clean up the leftover file. - Add
--require-revokeand make/unregisteridempotent. The cloud returns200for a credential it issued, whether it revokes it now or it was already revoked, and401only for a credential it never issued. Strict mode deletes on a confirmed200and refuses otherwise. This closes the idempotency trap and turns401into a precise "wrong cloud or unknown credential" signal. Choice (planned, not built). Make/unregisteridempotent (200for a known credential, revoked now or already;401only for one this cloud never issued) and add a--require-revokeflag that deletes the local credential only on a confirmed200. Strict mode cannot decommission offline, by design: a guarantee needs the cloud.--force(skip the prompt) and--require-revoke(require cloud proof) stay independent and compose.unregisteralso starts rejecting unknown options with a usage error, so a mistyped safety flag cannot silently fall back to a lenient delete. (The honest401message that names the wrong-cloud possibility and does not imply success already landed with D-0019; the idempotent contract lets it be tightened further, since a401would then mean only that this cloud never issued the credential.)
Consequences.
- The cloud's
/unregistermust remember revoked credentials so an already-revoked one still answers200. The real cloud must also issue fresh, unguessable, never-reused credentials, or revocation is meaningless (seeCLOUD_BACKEND.md). - Strict decommission requires connectivity; the default (D-0019) stays lenient so a reset or resale still works offline.
-
One more flag and stricter argument handling on
unregister. Revisit trigger. -
Built, this becomes accepted and D-0019's revisit note points here.
- The cloud cannot or will not keep a revoked-credential record, revisit whether strict mode can rely on
200-for-already-revoked.
D-0021: The cloud-assigned robot ID is stored in a second file beside the credential¶
Date: 2026-09-12 Author: Ambarish Status: accepted (amends D-0010, D-0011)
Context.
The real cloud assigns a durable robot ID at registration and returns it with the API key (robotDetails.id and robotDetails.apiKey). The robot must persist both: the API key is the rotating secret it authenticates with, and the ID is the stable identity the cloud knows it by, which later authenticated calls (reset, revoke, a future validate) will carry. D-0010 established a single plaintext credential file; there are now two values to keep. This also shifts the durable identity from the hardware serial (D-0011's anchor) to the cloud's robot ID.
Alternatives considered.
- One JSON file holding both values. One atomic write for the pair, but it breaks D-0010's plain-string on-disk contract (a container reads the credential file directly as a bare string), and it couples two values with different lifecycles and sensitivities into one blob.
- Two sibling files:
credential(the API key) androbot-id(the ID), both in/var/lib/tobor. Keeps the credential file's exact format and read contract, lets each value rotate and be protected independently, and leaves room to hardware-seal only the secret later. Costs true cross-file atomicity. - Store the ID only in memory / re-fetch it. Not possible; there is no cloud call that returns the ID for an existing robot without already authenticating, so the ID must be persisted.
Choice.
Two sibling files. credential keeps its D-0010 name, 0600 mode, and plain-string contract; the robot ID goes in a robot-id file next to it (same 0700 directory), also written atomically. Both are written on a successful register, the ID first and the credential last, so the credential file remains the "registered" marker: a crash mid-write leaves either nothing or an ID without a credential, and load_credential reads the latter as "not registered" (Missing) rather than corrupt. load_credential returns both values; a credential present without a readable ID is reported as an incomplete pair (Unreadable). remove_credential removes both, credential first.
Consequences.
- The on-disk credential format is unchanged (D-0010's MAJOR-bump contract holds); the ID is additive in its own file.
- The two files are each written atomically but not as one unit; the write/remove ordering makes a crash read as a clean "not registered", and an externally deleted
robot-idis caught as an incomplete pair. True pair atomicity (one JSON file) was deliberately not taken, for the reasons above. - The durable identity is now the cloud's robot ID, superseding D-0011's "serial is the anchor" for post-registration identity; the serial stays the register-time bootstrap identifier only.
- A future hardware-sealing step can protect the
credentialfile alone without touching the ID.
Revisit trigger.
- A need for atomic all-or-nothing across both values (rather than the commit-order guarantee) leads to a single file, accepting the read-contract change.
- Hardware-backed key storage arrives (Mode B direction): the
credentialfile becomes a sealed blob or a handle, the ID stays plain. - The cloud returns the ID as a number rather than a string, adjust parsing (see D-0022).
D-0022: Register, check, and transport reconciled to the real cloud API¶
Date: 2026-09-12 Author: Ambarish Status: accepted (amends D-0011, D-0016)
Context.
The cloud team delivered the real Mode A API, which differs from the shapes tobor had been built against the mock. Register is POST /api/robots/register (was /register) with a body of registerKey plus device{robotModel, firmwareVersion, serialNo} (was token plus device{serial, model, machine_id, os_release}), and it returns robotDetails{id, apiKey, status} (was a bare credential). Failure codes are 400 (register key not found, expired, or duplicate) and 403 (invalid key), plus 500/503. There is no robot-facing heartbeat or validate endpoint yet, so check's Bearer heartbeat (D-0016) has nothing real to call. This reconciliation aligns the client to that contract while deferring what the cloud has not yet built.
Alternatives considered.
- Keep the mock shapes and translate at a shim. Adds a layer for no benefit; the mock exists to match the real cloud, not the reverse.
- Reconcile everything at once, including reset and header auth. Larger surface than the cloud has settled; reset and the
X-Robot-ID/X-Robot-API-Keyheader auth depend on endpoints still in design. - Reconcile register plus storage plus status codes now; make
checkhonest; defer reset, validate, and header auth. Smallest correct step against the known contract.
Choice.
Reconcile the register path, body, and response, the status-code handling, and the credential storage (two values, D-0021), and make check health-plus-local until a validate endpoint exists. Register posts to /api/robots/register with registerKey and a device object; serialNo is required and comes from the hardware serial (dev override via TOBOR_DEVICE_SERIAL); robotModel and firmwareVersion are operator-supplied (TOBOR_ROBOT_MODEL, TOBOR_FIRMWARE_VERSION, with robotModel falling back to the device-tree model); machineId and osRelease ride along as metadata. Any device field that is empty is omitted from the body rather than sent as "". The response's robotDetails.id and robotDetails.apiKey are parsed (the ID assumed to be a string) and both stored. classify treats 403 as an auth rejection; register maps 400 to a "key not found, expired, or already used" message. check calls GET /health, then reports local registration status (naming the robot ID) instead of validating the credential against the cloud, since no such endpoint exists yet; the send_heartbeat transport is retained, unused, for the future validate endpoint and the serve loop.
Consequences.
- Breaking change to the register request and response contract, matched to the real cloud; pre-1.0 this is a MINOR bump. Amends D-0011's payload.
checkno longer proves the cloud accepts the credential; it confirms reachability and local registration only. Amends D-0016. The help text and docs say so.robotModel/firmwareVersionare operator-supplied because the board cannot know them; an empty optional field is omitted so a strict backend does not reject an empty string.- The robot ID is assumed to be a JSON string; if the cloud returns a number, the parse changes (D-0021 revisit).
send_revokestays live (used byunregister, D-0019);send_heartbeatis dormant, kept for the future validate/serve calls. Both still target the mock's Bearer endpoints and will be re-pointed when the real validate and robot-revoke endpoints land.- Open items handed to the cloud team: whether
robotModel/firmwareVersionare required or accept extras, the exact400-vs-403split, the robot-ID format, and the health-body contract.
Revisit trigger.
- The cloud ships a validate endpoint,
checkcalls it again (header auth), restoring credential validation. - The cloud confirms the robot-ID format, the required device fields, or the health body, tighten parsing and payload accordingly.
- Reset and header auth are built, add the
X-Robot-ID/X-Robot-API-Keytransport path (see D-0023).
D-0023: Post-register/reset credential confirmation and lifecycle (planned)¶
Date: 2026-09-12 Author: Ambarish Status: proposed
Context. Register (and, later, reset) hands the robot a credential, but the cloud has no way to know the robot actually received it and can use it, and a credential issued but never used should not linger. The design discussed with the cloud team adds a confirmation step and a credential lifecycle, plus a safe key rotation on reset. None of it is built; this records the intended shape before it fades.
Alternatives considered.
- No confirmation; treat a credential as live the moment it is issued. Simple, but a lost register response strands a live credential with no owner, and there is no early signal the robot is actually up.
- Let the first
serveheartbeat serve as the implicit confirmation. Fewer endpoints, but the cloud may wait a long time for a heartbeat, so it cannot expire abandoned registrations quickly. - A dedicated confirm/validate call the robot makes immediately after register/reset. An explicit, early "it landed and works" signal, at the cost of another endpoint and its own idempotency and expiry rules.
Choice (planned, not built).
After a successful register or reset, the robot saves the credential locally first, then calls a new authenticated confirm endpoint (its own, separate from serve's heartbeat), carrying the serial on the first confirm to bind hardware to credential. The cloud moves the credential from a pending state to active on that confirm and stops any expiry clock; a credential that is never confirmed expires. Only pending credentials expire; an active credential does not expire from idleness unless an operator later opts in. The confirm is idempotent (a repeat on an already-active credential returns success) and the robot retries it, so the two loss cases (request lost, response lost) converge; even if the ack never returns, a single received confirm has already activated the credential, so it works. Reset rotates the key with a grace overlap: the old key stays valid until the new key is confirmed, then the old key is retired promptly, so a lost reset response cannot lock the robot out. The robot treats a 403 on any authenticated call as "revoked or reset out from under me, re-register", never as a transient error to retry forever. Recovery from a lost or expired credential is operator-driven (a new token issued on request), since the connection is one-way and there is no push channel. A shared credential state machine (Pending, Active, Expired, Revoked, plus the reset overlap and Retired) is agreed with the cloud, and the cloud returns the current state so the robot and an operator can always see it.
Consequences.
- A new confirm/validate endpoint, a pending-until-confirmed lifecycle with an expiry window on pending only, idempotent confirms, and a reset grace overlap must all exist cloud-side.
- The robot gains a save-then-confirm step with its own retry, and re-points
send_heartbeat(or a new call) andsend_revokeat the real endpoints with header auth (X-Robot-ID/X-Robot-API-Key). - The serial-on-first-confirm binding is a soft consistency check (the serial is not secret and is overridable), not hardware attestation; it catches misconfiguration, not a determined attacker.
- Because recovery is operator-driven and tokens may be one-time, the pending-expiry window must be generous so routine timing never needs a human; a mass miss becomes operator work.
- There is no way to force-rotate a live robot's key without touching it; the compromise response is a cloud-authoritative revoke followed by an operator re-register.
Revisit trigger.
- The cloud ships the confirm/validate endpoint and the lifecycle, this becomes accepted and the robot side is built.
- Hardware attestation (TPM/fTPM) arrives, the soft serial binding is replaced by a cryptographic one.
- The one-way constraint is lifted (a downlink/push exists), forced rotation and push-driven recovery become possible.
D-0024: Credential write is made durable (fsync) and the temp file is created owner-only¶
Date: 2026-09-14 Author: Ambarish Status: accepted (amends D-0010)
Context.
D-0010 writes each credential file by writing a .tmp sibling and renameing it into place, and calls the result
"atomic": a reader always sees either the whole old file or the whole new file, and the ID-first/key-last order (D-0021)
makes an interrupted write read as "not registered" rather than corrupt. That covers a clean crash, but not a power cut.
rename only reorders directory metadata; the file's bytes and the new directory entry can still be sitting in the
kernel page cache, not on physical disk, when power is lost. On a robot, which loses power abruptly, that can leave a
zero-length or stale credential even though the rename "succeeded". D-0010 also created the temp file with
std::ofstream at the process umask (often 0644) and only tightened it to 0600 after writing, leaving a brief window
where the plaintext key sat on disk more open than intended; D-0010 recorded that window as an accepted residual.
Alternatives considered.
- Leave the write rename-only. Simplest, and correct for a clean crash, but not durable across a power cut, which is the robot's actual failure mode.
- fsync the file before the rename only. Makes the bytes durable, but not the directory entry, so after a power cut the data can be on disk while the new name is not.
- fsync the file before the rename and the parent directory after it. Both the contents and the name are forced to
physical storage, so the completed write survives a power cut. Costs dropping from
ofstreamto rawopen/write/fsync/close. - Keep
ofstreamand chmod after.ofstreamexposes no file descriptor, so there is no portable way tofsyncit, and the create-then-chmod window stays.
Choice.
Write the file through raw POSIX calls: open(tmp, O_WRONLY | O_CREAT | O_EXCL, 0600), a full write loop, fsync the
file descriptor, close, rename into place, then open the parent directory and fsync it. Two fsyncs: the file so
the key's bytes are durable, the directory so the new name is durable. O_CREAT | O_EXCL with mode 0600 creates the
temp file owner-only from its first instant and refuses to reuse a leftover temp file, removing D-0010's
create-then-chmod window. The rename and the ID-first/key-last ordering (D-0010, D-0021) are unchanged, so the
"interrupted write reads as Missing" guarantee still holds; durability is added on top of it.
Consequences.
- A completed register now survives a power cut, not just a clean crash: D-0010's "a crash or power loss can never leave a half-written or lost credential" is actually true now, where before the power-loss half rested on the OS flushing in its own time.
- The temp file is
0600from creation; the brief default-permission window D-0010 accepted is gone. - Because the temp name carries the PID and is opened
O_EXCL, a leftover temp file from a previous crash with the same PID makes the write fail cleanly ("couldn't open ... for writing") instead of silently overwriting it; the fix in that rare case is deleting the stale.tmp. The oldofstreamtruncated such a file instead. - If the directory
fsyncfails after the rename,write_file_atomicreports failure even though the file is in place, since durability could not be confirmed;save_credentialthen reports the write as failed. Very rare. - The write path is Linux/POSIX-specific (it already was, via
/var/liband the device-tree reads); it is portable across architectures because these are kernel system calls, not x86 specifics.
Revisit trigger.
- A future filesystem or platform where directory
fsyncis not the correct durability primitive -> adjust the flush step. - Hardware-backed key storage (Mode B): the
credentialfile becomes a sealed blob, and its write path is revisited.
D-0025: Mode A transport hardening (response cap, copied POST body, explicit libcurl global init)¶
Date: 2026-09-14 Author: Ambarish Status: accepted (relates to D-0003, D-0008)
Context.
A review of the libcurl transport (D-0003) found three latent issues, none a live bug on today's single-threaded client
against the trusted cloud, but each a trap worth closing before serve and the field. First, the response write
callback appended whatever the server sent with no limit, so a broken or hostile endpoint could grow the buffer until
the process was killed. Second, the POST body was handed to libcurl with CURLOPT_POSTFIELDS, which stores the caller's
pointer rather than copying it, so it was correct only because the body string happened to outlive the request. Third,
nothing called curl_global_init; the client relied on curl_easy_init to initialize libcurl's global state lazily,
which is not thread-safe and becomes a race the moment the planned serve command adds threads.
Alternatives considered.
- Leave all three. Each is harmless on the current single-threaded, trusted-cloud path. But the response cap is cheap DoS insurance, the copied body removes a refactor-time footgun, and the global init is far easier to add now than to debug as an intermittent crash later.
- Cap the response by reading
Content-Length. Spoofable and absent on chunked replies; a hard ceiling in the write callback is simpler and cannot be lied to. - Initialize libcurl globally but leave it implicit. Keeps
mainunaware of curl, but keeps the thread-safety trap.
Choice.
Three changes, kept behind the existing transport seam (D-0008). (1) The write callback enforces a fixed ceiling (1 MiB,
ample for the register/health JSON) and returns a short count once exceeded, which makes libcurl abort the transfer; the
existing error handling then reports it as a failed request. (2) The POST body is set with CURLOPT_COPYPOSTFIELDS (with
CURLOPT_POSTFIELDSIZE set first, so the exact byte count is copied), so libcurl owns a copy and the caller's buffer
lifetime no longer matters. (3) main calls a new http_global_init once at startup and http_global_cleanup once at
shutdown (thin wrappers over curl_global_init(CURL_GLOBAL_DEFAULT) / curl_global_cleanup, so main stays free of
curl and the transport owns its own lifecycle, D-0008); cleanup is tied to an RAII guard in main so it runs on every
exit path.
Consequences.
- An oversized or endless response is aborted instead of exhausting memory; a legitimate reply over 1 MiB would also be rejected, which the current endpoints never approach.
- The POST body no longer has a hidden lifetime requirement, so a future refactor that passes a temporary body cannot introduce a dangling pointer.
- libcurl's global state is set up once, before any thread exists, so the future
serveloop can create threads without racing the lazy initializer.--helpand--versionreturn before the guard is created, so they still need no network setup. - One consequence for the cloud team: a response body must stay under 1 MiB, and the cloud must answer within the
client's 5-second request timeout, or the client treats the call as unreachable and (for register) retries per D-0012.
Recorded in
CLOUD_BACKEND.md. - Two follow-ups this pass deliberately did not take, recorded so they are not lost: an oversized-response abort surfaces as a retryable "unreachable" outcome, so register will retry a deterministically oversized reply; and only two TLS error codes are treated as terminal (D-0013), so other non-verification TLS failures also retry. Both waste attempts but corrupt nothing; they belong with the retry-policy work in the cloud-API session.
Revisit trigger.
- A real endpoint legitimately needs to return more than 1 MiB -> raise or make the cap configurable.
- The retry classification is revisited (cloud-API session) -> decide whether a write-cap abort and non-verification TLS errors should be terminal rather than retried.
- Mode B replaces the HTTP transport -> these curl-specific hardening steps move to that transport.
D-0026: Register wire format moved behind the transport seam¶
Date: 2026-09-15 Author: Ambarish Status: accepted (amends D-0008, D-0011; relates to D-0009, D-0022)
Context.
cmd_register built the request body with nlohmann/json and parsed the response there too, so the command layer held the exact cloud field names (registerKey, device.serialNo, robotDetails.id, apiKey). D-0008 split transport from command, but the wire format still lived in the command, in two places (build and parse). With the cloud contract still in flux (D-0022) and the robot now leading parts of it, we wanted every register field-name assumption in one swappable spot.
Alternatives considered.
- Leave build and parse in
cmd_register. No new types, but the command owns the wire format, a cloud rename edits command logic, and the field names are scattered across the build and the parse. - Move only the response parse into the transport. Half the win; the request build still leaks the field names into the command.
- Move both build and parse into the transport, exchanging typed structs with the command. Every register field name in one file; costs two small structs and two functions.
Choice.
The transport gains RegisterRequest and RegisterResult and two functions: build_register_body (typed request in, JSON body out) and parse_register_response (body in, typed result out with a status), both in http_client.cpp. cmd_register fills a RegisterRequest from device info and environment config, reads a RegisterResult, and no longer includes nlohmann/json. The parse keeps its try/catch and reports Ok or Malformed through the result's status rather than by empty strings.
Consequences.
- Every register wire field name lives in
http_client.cpp; a cloud rename of the request or response is a one-file change and the command is untouched. commands.cppno longer depends on the JSON library for register; the D-0008 seam now covers the wire format, not just the request and response bytes.- Two small structs and two functions of new surface. The parse's failure modes collapse to a single
Malformedstatus (a malformed body and present-but-empty fields report the same message), a deliberate trade for a simpler result type. - Amends the D-0011 and D-0022 note that payload construction stays in the command layer; it now lives in the transport.
Revisit trigger.
- Mode B moves the wire format off JSON, these build and parse functions shrink or move to that transport.
- A message needs to distinguish a malformed body from present-but-empty fields, add a third status value.
D-0027: Authenticated calls carry a typed RobotAuth; auth headers come from one place¶
Date: 2026-09-15 Author: Ambarish Status: accepted (relates to D-0008, D-0021, D-0022, D-0023)
Context.
The transport authenticated a call by taking a lone credential string and formatting Authorization: Bearer inline in perform_request. The two-value store (D-0021) holds a robot ID and an API key, but only the key reached the wire, and the header name and scheme were baked into the shared request plumbing. The cloud's planned scheme is a header pair (X-Robot-ID / X-Robot-API-Key, D-0022 and D-0023), so the auth format will change.
Alternatives considered.
- Keep passing the API key string and format the Bearer header inline. Simplest, but the robot ID never reaches auth, and changing the scheme means editing the shared request plumbing.
- Pass both values but still format headers inline. The ID is available, but the scheme stays hardcoded in
perform_request. - Pass a typed
RobotAuth(robot ID + API key) and build the header(s) in one function that returns a list. Both values available, and the scheme is a single editable list.
Choice.
Authenticated transport calls take a RobotAuth carrying robot_id and api_key, and perform_request delegates to a single auth_headers(RobotAuth) that returns the list of header lines (today one: Authorization: Bearer <api_key>). Adding or removing a header is editing that list, and the robot ID is already in hand for the day the scheme adds X-Robot-ID. The command bridges its CredentialResult into a RobotAuth through one helper (auth_from).
Consequences.
- The auth scheme lives in one function. Moving from Bearer to the
X-Robot-ID/X-Robot-API-Keypair, or sending both, is a one-place change with no command or signature edits, since the ID already travels in the box. RobotAuthcarries the robot ID even though today's header does not send it, a small, deliberate bit of forward-looking state that pays off when the scheme changes.verify_credentialandsend_revokeshare the one auth path;send_heartbeatwill too when it wakes.
Revisit trigger.
- The cloud settles the authenticated scheme (header pair, signed token, other), edit
auth_headersto match. - Mode B (mutual TLS) removes bearer-style application auth,
RobotAuthandauth_headersfold into that transport.
D-0028: First-time credential verification is its own call; register and check verify against the cloud¶
Date: 2026-09-15 Author: Ambarish Status: accepted (amends D-0016, D-0022; relates to D-0023, D-0027)
Context.
register issued and stored a credential but never confirmed the cloud would accept it, and check (after D-0022) reported only local file presence, because the real cloud had no robot-facing validate endpoint. Both gaps share a root: nothing proved a stored credential actually authenticates. The robot design leads the cloud here, so rather than wait for the cloud to define an endpoint, the robot defines the smallest one it needs and takes it to the cloud team.
Alternatives considered.
- Keep
checkhealth-plus-local andregistersave-and-declare. Honest about today's cloud, but a revoked credential still reads as registered and a bad first-time credential is never caught. - Reuse the dormant
/heartbeatBearer call for verification. Fewer endpoints, but it folds first-time verification into the online-signal heartbeat, whose meaning and cadence will diverge underserve; keeping them separate is the point. - A dedicated verify call to a robot-defined endpoint, used by both
register(first-time) andcheck(on demand), status-only. One small call, its own function and path, separate from heartbeat; costs a new endpoint the cloud must implement.
Choice.
A verify_credential transport calls GET /api/robots/verify (a swappable placeholder path, isolated behind the transport) with the credential as a Bearer token; 200 means accepted, 401/403 rejected, and the body is not inspected (status-only, the smallest thing that answers the question). register verifies the freshly saved credential and reports success only once it is accepted; check runs GET /health then verify_credential. Verify is deliberately its own function and path, separate from the dormant send_heartbeat. register's handling of a non-accepted verify is pragmatic: an outright 401/403 rejection is a hard failure, a transient reachability or TLS problem leaves the completed registration in place and points the operator at check, and a saved-but-unreadable-back credential is a hard failure; the credential is kept in every case (no rollback). check reports verified, rejected (re-register), or a transport failure.
Consequences.
checkproves the cloud still accepts the credential, catching a revoked or rejected one that local file presence would miss. Amends D-0016 and D-0022, which reducedcheckto health-plus-local for lack of an endpoint.registerconfirms a first-time credential is usable before claiming success, closing the "issued but never proven" gap for the simple case. The fuller pending-to-active lifecycle (D-0023) is still planned and layers on top of this plain verify.- A new robot-facing endpoint the cloud must implement. The robot defines its shape (
GET /api/robots/verify, Bearer, status-only) as concrete feedback for the cloud team, and the placeholder path re-points with a one-line change when the cloud settles it. Recorded inCLOUD_BACKEND.md. - Verify uses today's Bearer header (D-0027) and will move to the header pair with the other authenticated calls.
checkmakes two round trips (health then verify). On a resource-constrained board this is a deliberate, minor cost, kept because health still lets an unregistered robot confirm reachability.
Revisit trigger.
- The cloud ships its own validate or confirm endpoint (D-0023), re-point
verify_credentialto it, adopt header auth, and fold in the pending-to-active lifecycle. - The verify body carries something worth checking (an echoed robot ID, a credential state), add a swappable parse as
registerdoes. - An operator wants a reachability-only check that does not touch the credential, split
check's two probes behind a flag (see D-0016 revisit). - D-0029: On a transient failure while verifying a credential (cloud unreachable or a TLS verify failure), register and check answer differently, and this is deliberate. register keeps a just-saved credential and exits 0: the register call itself returned 200, so a momentary blip while confirming should not discard a good registration. check exits non-zero: it is asked whether the credential is good right now, and if it cannot reach the cloud it cannot answer yes. The two commands optimize for different questions (register: did setup succeed; check: is the credential currently accepted), so the asymmetry is a feature, not a bug. Note that check today returns the same non-zero code for "the cloud rejected the credential" and "the cloud could not be reached"; separating those into distinct exit codes is a tracked follow-up. Amends D-0028.
D-0030: Build against Ubuntu 20.04 so one binary runs across the mixed-OS fleet¶
Date: 2026-09-25 Author: Ambarish Status: accepted (amends D-0001, D-0014, D-0015)
Context. A fielded unit appeared on Ubuntu 20.04 (Orin Nano, JetPack 5.x: glibc 2.31, gcc 9.4), alongside the 22.04 humanoids. The 0.5.1 binary, built on ubuntu-22.04-arm (glibc 2.35, gcc 11.4) per D-0014/D-0015, fails to load there: it requires GLIBC_2.34, GLIBC_2.32, and GLIBCXX_3.4.29, none of which 20.04 provides. glibc and libstdc++ are backward but not forward compatible: a binary built against an older glibc runs on newer systems, never the reverse. This is a D-0001 revisit trigger (a 20.04 unit joined the fleet). D-0001 deferred 20.04 behind an estimated week of work; on inspection most of that cost is moot or already paid (see Choice).
Alternatives considered.
- Keep building on 22.04; upgrade the 20.04 unit's JetPack to 6.x. Puts the cost on fleet ops per unit, is not always possible (JetPack is tied to the board and vendor image), and breaks again the moment another 20.04 unit appears.
- Ship per-OS binaries (a 20.04 build and a 22.04 build). Doubles the artifacts and forces robot-side selection, and re-creates the forward-compat trap for whoever fetches the wrong one.
- Build once in an Ubuntu 20.04 container against glibc 2.31. One artifact runs on 20.04 and every newer robot. Costs moving the build into a container and compiling with gcc 9.4.
Choice.
Build the aarch64 binary inside an ubuntu:20.04 container, on an arm64 host
runner (ubuntu-24.04-arm, since ubuntu-22.04-arm is on the D-0014
deprecation path). The host only provides the arm64 machine; the container sets
the glibc/libstdc++ floor at 2.31 / GLIBCXX_3.4.28. The deferred D-0001 retrofit
costs were checked and do not apply: tobor links no OpenSSL directly (all TLS is
via libcurl, D-0013; no compat header exists), so the 1.1.1 shim is unnecessary;
CMakeLists already links -lstdc++fs for gcc < 11 (pre-wired by D-0001), so
<filesystem> builds on gcc 9.4; the CMake floor is already 3.16 (D-0002). Both
aarch64-build.yml and release.yml move into the container.
Consequences.
- One binary runs on the whole current fleet (20.04 Orin Nano and 22.04 G1) and every newer OS; no per-OS builds. Amends D-0001's "fielded 20.04 units cannot run this binary."
- The container base is now a floor contract: raising it to a newer image breaks 20.04 robots again. Guarded only by this entry and the workflow comments.
- Releases compile with gcc 9.4 / libcurl 7.68 (OpenSSL 1.1.1 flavour) inside the container. At runtime the binary loads each robot's own libcurl, so a 22.04 robot still uses its 7.81 / OpenSSL 3.0; tobor's only ABI dependencies are libcurl.so.4, glibc, and libstdc++, all backward compatible.
- The host runner moves to
ubuntu-24.04-arm(a D-0014 revisit), independent of the binary's compatibility, which the container sets. If that label retires, switch it or use the QEMU fallback (D-0014); the container is unchanged. - The slim container lacks tools the full runner image had, so the workflows now
install
git,ca-certificates,file, andgawkexplicitly. - D-0001's only still-deferred item is the OpenSSL 1.1.1 branch, and it stays deferred because it is still unused.
Revisit trigger.
- The fleet drops all 20.04 units -> the floor can rise back toward 22.04 (D-0001's original) and the container can go.
- tobor gains direct OpenSSL usage -> the D-0001 1.1.1 compat branch must exist before the 20.04 container build will compile.
- A unit appears on an OS older than 20.04 (glibc < 2.31) -> lower the container base or reconsider; note Node 20 based actions also stop working below 20.04.
ubuntu-24.04-armretires -> switch the host label or use QEMU (D-0014); the container is unchanged.
D-0031: Header include guards revert to #ifndef¶
Date: 2026-09-30 Author: Ambarish Status: accepted (supersedes D-0004)
Context.
D-0004 chose #pragma once on the grounds that tobor's compiler set is closed and known (gcc/clang on
x86_64 and aarch64), so the universal portability that #ifndef guards buy is worth nothing here. That
reasoning still holds on its own terms. What changed is a second reason to care: a hardening pass over the
client applied MISRA C++:2023, whose Rule 19.2.1 requires a standard include guard on every header.
#pragma once is a compiler extension, not part of ISO C++, so it cannot satisfy that rule however widely
it is supported. The robot-side safety code still to be written (watchdog, emergency stop, teleop
fail-safe) is the kind of software MISRA exists for, and a split convention across the codebase would be
worse than either choice applied consistently.
Alternatives considered.
- Keep
#pragma once. No churn, D-0004's reasoning intact. Costs a documented deviation against a required MISRA rule in every header, for a benefit (shorter headers, no guard-name collisions) that is real but small. - Split:
#ifndefin future safety-critical components,#pragma oncehere. Keeps both decisions locally optimal. Costs a convention that differs by directory, which is the kind of thing new contributors get wrong and reviewers stop noticing. #ifndefguards everywhere. One convention, one fewer deviation to justify later. Costs three lines per header and reintroduces the guard-name collision footgun D-0004 wanted to avoid.
Choice.
#ifndef include guards across all headers, named TOBOR_<FILE>_HPP. The MISRA rule is the deciding
factor: the guard-name risk D-0004 identified is real but mitigated by the TOBOR_ prefix and by the
fact that a collision produces a loud compile error rather than silent misbehaviour, while a deviation
record is a permanent cost paid every time the standard is applied. Uniformity across the codebase beats
optimising each component separately.
Consequences.
- Every header opens with
#ifndef TOBOR_<FILE>_HPP/#defineand closes with a matching#endifcarrying the guard name as a comment. - A new header needs a unique guard name; a mis-copied one is a compile error, not a silent double inclusion, because the second file's contents are skipped and its declarations go missing.
- Vendored third-party headers (
include/nlohmann/json.hpp) are out of scope and keep whatever they use. - D-0004's Status becomes
superseded by D-0031.
Revisit trigger.
- MISRA is dropped as a reference standard for this codebase, removing the only reason to prefer
#ifndefhere. - A guard-name collision actually occurs, suggesting the mitigation is weaker than assumed.
D-0032: MISRA is a reference standard here, not a compliance target¶
Date: 2026-09-30 Author: Ambarish Status: accepted (clarifies D-0031)
Context. D-0031 reversed D-0004 on MISRA grounds and its revisit trigger reads "MISRA is dropped as a reference standard for this codebase," which a reviewer reasonably read as formal adoption. That overstates the decision actually taken. Formal compliance under MISRA Compliance:2020 requires a guideline enforcement plan mapping every guideline to how it is checked, plus a deviation record for each required rule not followed. None of that exists, and for this component none of it should.
Alternatives considered.
- Pursue formal compliance now. Honest about the claim, but the client is not a safety function: if registration fails an operator runs a command again. The permanent cost of maintaining deviation records buys nothing a customer or certifier is asking for today.
- Drop MISRA entirely. Removes the ambiguity, and loses a rule set that found real weaknesses in this pass at almost no cost.
- Name the middle position explicitly. MISRA informs the code; no compliance is claimed; enforcement is whatever free tooling reaches.
Choice. MISRA C++:2023 is a reference for this codebase, not a compliance target. Guidelines are applied where they improve the code and skipped where they would find nothing here. No compliance claim is made, in the repo or to anyone outside it.
Enforcement is the strict compiler warning set in ../CMakeLists.txt and
../.clang-tidy, whose CERT, C++ Core Guidelines and HICPP checks overlap
MISRA substantially. Neither is MISRA: no free tool implements it, and the
guidelines themselves are a paid document. The current clang-tidy baseline
is 33 findings, none of them defects.
Third-party code is out of scope. nlohmann/json (D-0009) is exception-based
by design, which sits awkwardly beside MISRA's constraints on exceptions.
Both throwing call sites are confined to src/http_client.cpp and caught at
its boundary: json::parse in parse_register_response and dump in
build_register_body. main carries a catch-all around command dispatch so
nothing reaches std::terminate. The non-throwing forms
(parse(..., nullptr, false), dump(..., error_handler_t::replace)) were
considered and rejected for the register body: replace substitutes U+FFFD
into a serial number, and silently altering an identifier would have the
cloud record a serial the robot does not have.
Consequences.
- No deviation records are maintained, and none are needed while no compliance is claimed.
- The warning set and clang-tidy config are the enforcement; a finding in either is addressed or explicitly disabled in the config with a reason.
- Safety-critical components written later (watchdog, emergency stop, teleop fail-safe) are a separate question. If any of them needs a real compliance claim, that needs the paid guidelines, a commercial checker and a guideline enforcement plan, and should get its own decision entry.
Revisit trigger.
- A customer, certification body or regulation asks for a MISRA compliance claim for this component.
- Safety-critical code lands in this repository rather than beside it.
- A free or affordable checker implements MISRA C++:2023, making real enforcement cheap.