Skip to content

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.4 quirks such as -lstdc++fs for <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:

  1. 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.
  2. CMakeLists.txt adds -lstdc++fs as a conditional link flag guarded on CMAKE_CXX_COMPILER_VERSION VERSION_LESS 11. Inert on g++ 11.4, ready to activate on g++ 9.4.
  3. Code style avoids C++17 library corners known to be flaky on g++ 9.4: no constexpr std::optional past the trivially-constructible case; no <charconv> for floats; care around std::string_view implicit conversions.

Consequences.

  • Simpler code today: no OpenSSL 1.1.1 branch to maintain, no g++ 9.4 workarounds.
  • 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 tobor on Ubuntu 20.04 as a contract term.
  • More than five robots on Ubuntu 20.04 join the fleet.

When triggered:

  1. Add the OpenSSL 1.1.1 branch to src/compat/openssl_compat.hpp.
  2. Activate the -lstdc++fs link condition in CMake.
  3. Sweep the codebase for g++ 9.4-incompatible C++17 library usage.
  4. Add Ubuntu 20.04 aarch64 and x86_64 jobs to GitHub Actions CI.
  5. 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.txt needs 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.txt stays at cmake_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.txt uses find_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.

  • #ifndef include 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 once as 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_health takes 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 register and 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 in ps and 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_health originally 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_client moves bytes and reports ok/status/body; cmd_run/cmd_register decide 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_client is pure transport; libcurl and result-shaping stay inside it, and callers see only HttpResult.
  • 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_client as diagnostics; HTTP-level interpretation (200 vs 401) is the command's job.

Revisit trigger.

  • Mode B's transport (MQTT) is added behind an interface; HttpResult generalizes 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 in include/nlohmann/, #include <nlohmann/json.hpp>, done - header-only, so no find_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.txt needs nothing for it - no find_package(nlohmann_json), no target_link_libraries entry. 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 in try/catch and 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 so run would 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 rename into 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 that register needs no sudo. Dev on x86 sets TOBOR_CREDENTIAL_PATH to 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 0600 file and in process memory - not in logs, docker inspect, or terminal output.
  • Accepted residual exposures: root can read the file (true of any plaintext secret on the box); a backup of /var/lib carries the credential in plaintext (backup hygiene is required); and the .tmp file exists briefly at default permissions during the write before it is tightened to 0600 (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 root or 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 systemd unit runs run as a dedicated system user or root rather than the unitree login 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_PATH becomes 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 DMI product_serial.
  • OS details. Parse PRETTY_NAME (and strip quotes) inside the binary; or send the raw /etc/os-release and 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 /register request 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 JSON credential) 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_release lives 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 device block generalizes or is replaced.
  • A field needs server-side structure the raw os_release cannot 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 register on 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 in cmd_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-on run path, 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 of cmd_register into 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-certificates installed 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) and CURLE_SSL_CACERT_BADFILE (the CA bundle could not be read, usually a bad TOBOR_CA_BUNDLE path). 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_BUNDLE on 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 apt install 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-arm is 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_request to 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 CMakeLists bump 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-arm runner 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_notes with a CHANGELOG-derived body.
  • ubuntu-22.04-arm retires (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 run as-is. No churn, but the name keeps overpromising, and there is no clean name left for the future daemon.
  • Rename run to check for the one-shot, reserve serve for 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.

  • run no longer exists (unknown command); a CLI-contract break, which pre-1.0 is a MINOR bump. Docs and examples move to check.
  • check now 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 serve loop will reuse, so this is a down payment on it, not throwaway work.
  • No back-compat run alias is kept; the command was only in 0.4.0 and the docs are updated in the same change.

Revisit trigger.

  • serve is built and its loop lifts send_heartbeat into 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 401 maps 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 (register reads the first arg as the token; check ignores 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 and optional. No new types, but the interpretation logic is copied per command and "why did the load fail" cannot be expressed.
  • Named outcome types. An HttpOutcome enum with a classify(HttpResult) that interprets a response once; a CredentialResult (a CredentialStatus plus 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 optional it 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), HttpOutcome generalizes 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. unregister deletes 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 typed yes (only an exact yes proceeds; anything else, or no input at all, aborts and leaves the file, exit non-zero). --force skips the prompt for automation. After confirmation it POSTs to /unregister with the credential as a Bearer token: a 200 confirms the revoke; any other outcome (unreachable, TLS failure, or a 401) is reported but does not stop the delete. A 401 is 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 in credential_store (remove_credential, with a credential_path accessor); 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 (see CLOUD_BACKEND.md). The mock implements it; the real cloud is the cloud team's to build.
  • remove_credential returns 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 /unregister contract changes, update send_revoke and 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 401 ambiguity stays.
  • Add --require-revoke on top of today's 401 semantics. 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 answers 401, and strict mode then refuses to ever clean up the leftover file.
  • Add --require-revoke and make /unregister idempotent. The cloud returns 200 for a credential it issued, whether it revokes it now or it was already revoked, and 401 only for a credential it never issued. Strict mode deletes on a confirmed 200 and refuses otherwise. This closes the idempotency trap and turns 401 into a precise "wrong cloud or unknown credential" signal. Choice (planned, not built). Make /unregister idempotent (200 for a known credential, revoked now or already; 401 only for one this cloud never issued) and add a --require-revoke flag that deletes the local credential only on a confirmed 200. 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. unregister also starts rejecting unknown options with a usage error, so a mistyped safety flag cannot silently fall back to a lenient delete. (The honest 401 message 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 a 401 would then mean only that this cloud never issued the credential.)

Consequences.

  • The cloud's /unregister must remember revoked credentials so an already-revoked one still answers 200. The real cloud must also issue fresh, unguessable, never-reused credentials, or revocation is meaningless (see CLOUD_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.