It is the one artifact of this repo that lands on a developer's PATH, and `btctl` claims a name in that namespace that says nothing about what it controls. It sits next to `bluetoothctl`, `btmgmt` and `btmon` — all of which drive a Bluetooth stack rather than a robot — and a year from now, in `~/.cargo/bin`, nothing distinguishes it from one of them. `duck-btctl` says whose robot, matches the prefix the non-robot-local crates already carry (`duck-control`, `duck-ipc-proto`), and is what `duck-<Tab>` finds when the name has been forgotten. `robotctl` and the daemons keep their bare names: they live on the robot's filesystem, where the context is not in doubt. `--version` is part of this. It answered `btd 0.5.1` — clap takes the name from the crate unless told otherwise, so the laptop client reported itself as the daemon it is an example of. Anyone who installed the old name keeps a stale binary that no longer tracks the branch, so the page says to remove it. Assisted-by: Claude:claude-opus-5
18 KiB
Roadmap
Status: draft · Date: 2026-08-05 · Owner: pierre
Companion to architecture.md (what we're building) and
updater-design.md (how it ships). This is order and sequencing
— it will change; the design docs shouldn't.
Where we are
updater/ |
engine, verification, store, journal, hooks, preflight, GitHub/HF/local sources, IPC server, systemd unit — done |
duck-control/ |
robot model · bus · IMU · RobotIo · observations · ONNX policy · safety — slices 1–2 done, and run on a robot. A library: no tokio, no sockets, no systemd |
duck-ipc-proto/ |
wire contract for update.* and robot.* — done; serde/serde_json/semver only, so nothing on the recovery path pulls the engine's tree |
robotd/ |
a real 50 Hz loop driving walk/stand through the safety layer, intents, health from deadline adherence and policy state — slices 1–2 done, and it walks on a board; no kinematics |
padd/ |
gamepad → intents, as an ordinary socket client — done, ships in the release and runs as its own unit from boot, so pairing a pad is the only step; needs libudev, installed by CI and the board cross-build |
robotctl/ |
the operator CLI — update, health, version, monitor, net, system, pad, completions; depends on duck-ipc-proto, not updater, so it stays on the recovery path |
xtask/ |
package · sign · promote — done, byte-identical promotion verified |
.github/ |
ci · release · promote — all three run for real: 0.2.0 was tagged to staging, verified through the engine, installed on a board and promoted to stable on 2026-08-05, byte-identical (§16.3) |
| bootstrap | updaterd install + scripts/install.sh — a robot installs its first release through the ordinary engine, so there is no bootstrap-only code path to drift |
deploy/ |
shipped updater.toml, robotd.toml, trust anchor, journald retention drop-in |
scripts/ |
install.sh provisioning · board-test.sh — passing in CI: 13 checks on emulated aarch64, Debian 13 (Trixie) |
btd/ |
BLE transport adapter — framing, the routed subset, the BlueZ backend, a pairing agent, plus duck-btctl for a laptop. Works on hardware, unencrypted by default — the blocker, app-path-design.md §5.5 |
configd/ |
wifi over NetworkManager, robot name and the identity it derives from the SoC serial, pairing PIN, reboot. Drives a real NetworkManager on a board: provisioned over BLE, joined, and rejoined by itself after a reboot. --fake-net still serves the whole surface off-board |
| tests | 458 passing, including the health gate, the battery+thermal readout and the policy/safety path against a real robotd process, and configd's authorisation over real sockets in board-test.sh |
| missing | mediad, app, SDK |
| on hardware | walking through the intent API, the update path (install · health gate · commit · auto-rollback), a signed release installed from the stable channel, BLE provisioning of wifi. The loop held 50.0 Hz with missed=3 in 15022 ticks before inference |
| not on hardware | the numbers M4 exists for: thermals, eMMC write timing, battery under load, and whether logs survive a power cut. The 30s health-gate timeout is still a guess |
The framing
The hard part is productisation, not capability. microduck_runtime already walks,
runs gait policies, does perception and mapping. What doesn't exist is a robot you can
hand to a stranger: app-driven updates, safety authority, privacy, provisioning,
recovery. Porting existing capability into the new architecture is laborious but
known-feasible; the unknowns are all on the productisation side.
The updater is finished and instrumentally useless. It had nothing real to ship, which
was the whole argument for the ordering below. That is now over: 0.2.0 ships a robot that
walks, and the update path is how it got onto a board.
What changed the order: the team arrives (written 2026-07-28)
Others will work on robotd/mediad and share builds through the updater. That
makes two things urgent that would otherwise have waited:
- Dev-channel installs — install a specific branch or commit on a board, without
cutting a release. This is now ahead of
btd: teammates will userobotctl, not the phone app. A repo and a dev signing key— done.pollen-robotics/microduck_daemon(private), CI green on first fix;team.devkey generated. The signing secrets and thereleaseenvironment are in place too, and0.2.0went out through them.
Both landed early anyway — see M6 for why the
trigger turned out not to be the phone app.btd and the app path slip behind both.
Milestones
Each has a test that says "done", because milestones without one drift.
M1 — Close the loop · done
The updater got something real to gate against, and the team got a shared crate boundary.
robotdskeleton — heartbeat plus the fourrobot.*methodsupdaterdcalls. Its state is atomics, not a mutex: a robot whose control loop is wedged must still be able to answer "I am not healthy", and needing the loop's lock to answer would hang in exactly the case that matters.--unhealthy/--busyexercise rollback on a bench robot.duck-ipc-protoextracted —robotdandrobotctldepend on it and not onupdater, so nothing on the recovery path links the engine's http/tar/crypto tree.- The health gate is real —
on_applyrestartsrobotd,healthis a socket probe, and a test fails if either regresses to its inert bootstrap value. - One source of truth for the robotd socket —
robot_socketat the top level of the config;--robot-socketis a documented dev override. - Logging and version reporting — every daemon's first line is its own identity (version,
revision, exe path) at
warn, so it survivesRUST_LOG=warn;robotctl versionreports running and installed per service, becauseupdaterdnever restarts itself and so legitimately lags until reboot. - First-install bootstrap —
updaterd install+scripts/install.sh, through the ordinary engine.
Done: robotd/tests/updater_gate.rs gates an update against a real robotd process over
a real socket and commits; robotd --unhealthy reverts the content behind current.
robot-config was dropped from this milestone: M1's test is about the health gate, and a
heartbeat daemon needs CLI flags, not a shared config store. It lands when something reads it.
M2 — Dev channel · done
Install a branch on a board without cutting a release:
sudo robotctl update apply daemon --ref my-branch
Target::Refandmanifest_at_refon the source trait.--refconflicts with--versionrather than one silently winning.dev.yml— every branch push publishes<crate>-dev.<run>.<sha7>to the moving tagdaemon-dev-<branch>, signed withteam.dev.xtask packageaccepts a prerelease of the crate version without--allow-version-drift, so the escape hatch stays reserved for what it was built for.- Refs work on
local_dirtoo, which makes the path testable offline and is the sideload story. A ref becomes a filename there, so separators and..are refused.
Two properties make this safe on every push, both enforced away from the workflow:
- A dev build cannot become
latest— the version is a semver prerelease, andversion_underrefuses to read a dev tag as a release version. - A dev build cannot install on a customer robot —
allow_dev_keysis false there, and a trusted key only counts as a dev key if its filename ends.dev.pub.
A ref bypasses the downgrade guard by design: a prerelease always sorts below the release a
board is on, so guarding it would refuse every branch install. A plain apply returns the
board to the release stream, since latest resolves to the highest stable version.
apply --from <dir>andscripts/dev-push.sh— build on a laptop, install on a board, no push and no CI run. A per-call source override rather than a config edit, so the board keeps reaching GitHub for--ref,--stagingand a return to the release stream. It is an ordinary apply: health gate, auto-rollback, dev-key verification.API_VERSIONmoved with it, because a daemon one version older would have parsed the option, ignored it, and installed from its configured source while reporting success. v7 first ships in 0.5.0, so each board takes onescripts/dev-push.sh --bootstrapto get there — the binary that would gate the update is the one being replaced.
Done: verified against the real repository — dev.yml published, --ref main installed
over the network, and a customer-robot config refused the same build.
Open, and it blocks M4: a private repo's release assets need a token, and a customer robot
has none. See updater-design.md §6.1.
M3 — robotd for real · done, in two slices
Designed in robotd-design.md. robotd replaces
microduck_runtime, by extracting its control core into duck-control rather than
reimplementing it — so the prototype keeps running while the daemon grows, and parity
arrives as a consequence of the extraction instead of as a race against a moving target.
Only the alpha variant on the Radxa survives; the other three variants, four IMUs and two
boards are dropped.
Hardware first, sim after. An earlier draft of this milestone said the reverse. It was
wrong on the facts: there are boards, and correctness gets settled on them. The simulator's
job is a clean laptop dev environment, not a validation oracle, so it lands after slice 2
and never becomes a second definition of what the robot is. Tests run against a FakeIo
backend — no hardware, no network, no Docker, no Python.
Slice 1 — hold the pose · done, on a board. A real 50 Hz loop on the Dynamixel
bus, holding whatever pose it starts in. No policy. It exists so robot.health means the
loop is meeting its deadline rather than it ticked once — until now the updater's
auto-rollback has been gating on a placeholder. Holding a pose is also what makes it safe to
hammer install/rollback/power-cut cycles at a bench for a day.
Slice 2 — walk and stand. One 61-D observation builder (every alpha policy is
obs[1,61] → actions[1,14]), the main-plus-standing policy shaped as it is in the runtime,
move/head/stop/enable intents, and a gamepad client that goes through them.
Safety authority belongs here, not in M6 — architecture.md §6 designs it and nothing
implements it. It lands in slice 2, holding the only write handle to the bus, so no policy
and no client can command a motor around it. Joint clamp, fall → limp, and an intent
deadman; thermal waits for a measured threshold rather than a guessed one.
Done: all three, on a Radxa Zero 3W. It walks driven through the intent API; an update
applied with robotctl restarts it cleanly with the gate passing; and a release that comes up
unhealthy reverts on its own. The board also produced the one bug the tests could not: ort
panics rather than erroring on a runtime below its floor, which killed the control thread and
made health blame the wrong thing. It now holds the pose and names the version instead.
M4 — Hardware bring-up · in progress
M3 on the Radxa with real motors and IMU. This is where the genuinely unknown numbers
appear: control-loop jitter on a non-RT kernel, ONNX inference rate on Cortex-A55, eMMC
write timing, thermals, battery. Also the first real test of systemctl restart in
on_apply, and of the health-gate timeouts — 30s is currently a guess.
Settled already, because slices 1 and 2 could not be finished without them: the loop holds
its rate on a non-RT kernel (50.0 Hz, missed=3 in 15022 ticks, before inference), the bus and
the imu_to_dxl board answer on /dev/ttyS2, systemctl restart in on_apply works against
real systemd, and the gate commits and reverts for real. Still open, and they are the reason
this milestone is not closed: thermals, eMMC write timing, battery under load, whether the 30s
timeout has any margin on a cold boot, and the log-retention question below.
Also the first chance to settle the log retention question, which cannot be answered
off-board (deploy/README.md):
findmnt /var/log— if Armbian's RAM-log has it on tmpfs, journald'sStorage=persistentis a directory in memory: it survives a cleanrebootand loses recent logs on a power cut, which is how a robot is actually switched off. Decide explicitly: disable the RAM log and accept eMMC writes, or keep it and rely on the update history (which does not go through/var/log).journalctl --list-bootsafter a real reboot — two or more entries, or the drop-in is not doing what it claims.
Done when: it walks on hardware, an update applied via robotctl restarts robotd
cleanly with the gate passing, and journalctl -u robotd -b -1 returns the previous boot's
logs after a power cut.
M5 — mediad, WebRTC, SDK
Camera/mic, encode, perception, the remote gateway. Privacy lands here and not later: per-session consent and a visible streaming indicator are cheap now and expensive to bolt on. The SDK's WebSocket + snapshot path (§5.3) is what makes "an LLM drives the robot" easy.
Done when: telepresence works from outside the LAN, and a server-side script can fetch a frame and send an intent in a few dozen lines.
M6 — Ship readiness
btd + the app update path, provisioning (device identity, calibration, key
installation), recovery mode (§8.2's last link), manifest staleness reporting (§8.4.2),
and the authority arbitration finished.
btd and configd landed early, out of this order. The trigger was wanting to configure a
robot — wifi, name, reboot — from something other than an SSH session, and the work turned out to
be mostly not Bluetooth: an API surface and a service to own it, which the phone app, the SDK,
robotctl and mediad's gateway all need identically. btd is a thin pipe over it.
What that leaves for M6 proper: recovery mode, manifest staleness, authority arbitration, and the provisioning step the pairing PIN now depends on (below). It also means the app has something to talk to before the app exists, which is the right order for finding out that an API is wrong.
Done when: a non-developer updates the robot from the phone, and a deliberately bricked release recovers without a laptop.
Organisation
One repo, one workspace. robotd, btd, configd and padd have joined as siblings;
mediad is the one still outstanding. They co-version because they all ship in the same
daemon artifact — one version line is correct, and models version separately already.
Crate layout as it should end up:
duck-ipc-proto/ wire types — serde/serde_json/semver only; btd/robotd/robotctl depend
on this, never on updater
configd/ wifi (NetworkManager), robot name, pairing PIN, reboot, gamepad pairing (BlueZ)
updater/ engine + updaterd
robotctl/ CLI
robotd/ control, gait, safety — no kinematics yet
padd/ gamepad → intents; a client, with no privilege the app will not have
mediad/ camera, encode, perception, WebRTC gateway (not built yet)
btd/ BLE transport adapter + duck-btctl (dev client)
xtask/ build/publish tooling — never ships
Docs per concern, not per service. architecture.md is the cross-cutting contract;
a service gets its own design doc only when it earns one (updater-design.md is the
model). Resist one giant document.
Two channels of work for newcomers. A teammate on robotd should be able to: clone,
cargo test, run against sim, push a branch, and install it on a board via --ref.
That's the whole onboarding path, and M1+M2 are exactly what make it true.
Decisions that shape work rather than follow it
Signing key custody— done. Three encrypted release keys plus an unencrypted dev key in~/.duck-keys, all round-trip verified; onlyrelease-1goes into secrets. Releases are signed in CI underenvironment: release, which on this plan scopes the secrets but gates nothing — no required reviewers, no branch policy, so anyone with push access can reach the key. Accepted deliberately while no robot is in the field, and the declaration is the hook that turns a real gate on with one settings change. Seeci-setup.md.- Safety authority (§6) — pulled into M3 for the reason above.
- Provisioning — still needed, but for less than it was. Identity no longer waits on it:
a robot derives one from its own SoC serial and names itself
duck-c51b, so a board flashed by hand is distinguishable out of the box (app-path-design.md§8.2). What is left is calibration and the PIN — BLE pairing security rests on a per-robot PIN, the factory default is000000and public in this repository, so out of the box pairing proves physical presence and nothing more. Something has to generate one, print it, and record what was printed. Note the PIN cannot come from the identity, which was the plan: the identity is published in an advertisement, so anything derived from it is public too. - Privacy — consent + indicator in M5, not M6.
Not doing, on purpose
Recorded so they stay decided: A/B image updates, OS/kernel OTA, fleet dashboards/telemetry, delta updates, staged rollouts, hardware capability matrix, competing model alternatives per slot (§17), peripheral firmware OTA (§11.1).