From 006e72ca2a52e087add0c58b9a1f729439cb251a Mon Sep 17 00:00:00 2001 From: Pierre Rouanet Date: Wed, 26 Aug 2026 17:30:07 +0200 Subject: [PATCH 1/4] Roadmap: the first one reached its target, so this is the next one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 to M4 were a sequence with one destination — a robot that walks, updates itself over the air, and cannot be bricked doing it. That arrived at 0.9.x. The work left is independent tracks with no ordering between them, so the milestone numbers stop being a sequence and an "order of work" section carries that instead. Three milestones are new: - **M7 — knowing what a board is doing.** A duck in someone else's hands will develop a fault, and today the answer to *which part* is a developer reading a journal over ssh. Framed as the questions it owns rather than a design: a support bundle, a hardware pass/fail a non-developer can run, history for intermittent faults, and the overlap with #52 — whose crash record is exactly the durability a fault report needs. M4's two unmeasured numbers land here. - **M8 — the model channel.** Policies ship inside the daemon artifact today, so a new gait needs a daemon release. The engine was designed for the other arrangement and most of it exists: the `hf_hub` source verifying our own minisign, per-model components with their own version line and rollback, and `action = "reload"`. What is missing is at the ends — `robotd` cannot reload, nothing publishes a bundle, and publishing a policy is a third kind of signing key. - **M9 — the autonomous brain**, with the shape argument that matters: presence, mood and the beat are inputs to one brain, not modes beside it. M4 closes with its log criterion rewritten. It asked for logs surviving a power cut; `/var/log` is zram and the durable record is the update history, so the criterion was asking for a property this project decided not to have. What is left of it is one `journalctl --list-boots` on a board. M5 gains the Python client question — whether one client covers both transports, WebSocket for control and snapshots and WebRTC only for a stream — and splits privacy in two, because consent is unblocked and the indicator waits on hardware that does not exist. M6 is reordered by lead time rather than difficulty, and promotes what was an M2 footnote: a private repo cannot serve the fleet, so as things stand robots in the field cannot download anything. With preorders open that is the longest-lead decision on the page. Assisted-by: Claude:claude-opus-5[1m] --- docs/project/roadmap.md | 561 +++++++++++++++++++++------------------- 1 file changed, 292 insertions(+), 269 deletions(-) diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md index 35cec3ba..b37b3405 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -1,6 +1,6 @@ # Roadmap -Status: draft · Date: 2026-08-05, revised 2026-08-26 · Owner: pierre +Status: rewritten · Date: 2026-08-26 (first written 2026-08-05) · Owner: pierre Companion to [`architecture.md`](../design/architecture.md) (what we're building) and [`updater-design.md`](../design/updater-design.md) (how it ships). This is *order and sequencing* @@ -11,301 +11,324 @@ Companion to [`architecture.md`](../design/architecture.md) (what we're building | | | |---|---| | `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**. Since then: kinematics, contact odometry, the voice, the ToF theremin and the chorale, all hung off the same tick ([`robotd-design.md`](../design/robotd-design.md) §4.4–4.5) | -| `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 | +| `duck-control/` | robot model · bus · IMU · `RobotIo` · observations · ONNX policy · safety. A library: no tokio, no sockets, no systemd | +| `duck-ipc-proto/` | wire contract for every `*.` namespace, at API v14 — serde/serde_json/semver only, so nothing on the recovery path pulls the engine's tree | +| `robotd/` | a 50 Hz loop driving walk/stand/roll through the safety layer, intents, health from deadline adherence and policy state. Since M3: kinematics, contact odometry, gaze IK, the voice, the ToF theremin and the chorale, all hung off the same tick ([`robotd-design.md`](../design/robotd-design.md) §4.4–4.5) | +| `padd/` | gamepad → intents, as an ordinary socket client; ships in the release and runs as its own unit from boot, so pairing a pad is the only step | | `robotctl/` | the operator CLI — `update`, `health`, `version`, `monitor`, `net`, `system`, `robot`, `pad`, `configure`, `quack`, `chorale`, `theremin`, `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) | +| `configd/` | wifi over NetworkManager, robot name and the identity derived from the SoC serial, pairing PIN, reboot, unit reporting. `--fake-net` serves the whole surface off-board | +| `btd/` | BLE transport adapter — framing, the routed subset, the BlueZ backend, a pairing agent. Works on hardware, unencrypted by default — [`app-path-design.md`](../design/app-path-design.md) §5.5 | +| `duckctl/` | the robot from a laptop. BLE today; named for the robot rather than the radio | +| `mediad/` | camera, mic, encode and the WebRTC gateway, plus the console it serves. **Streaming to a browser on the LAN from a Radxa Zero 3W**, hardware H.264 through `mpph264enc`, `control` datachannel alongside | +| `tof/` | `tofd`: the head's 8×8 ToF matrix on its own socket at 15 Hz. A board with no sensor fitted runs it anyway and says so | +| `xtask/` | package · sign · promote — byte-identical promotion verified | +| `.github/` | ci · release · promote · dev — all four run for real; every release since `0.2.0` reached a board through them | | 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. **Works on hardware**, unencrypted by default — the blocker, [`app-path-design.md`](../design/app-path-design.md) §5.5 | -| `duckctl/` | The robot from a laptop. BLE today; named for the robot rather than the radio, because `mediad` is a second transport | -| `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 | -| `mediad/` | camera, mic, encode and the WebRTC gateway, plus the console it serves. **Streaming to a browser on the LAN from a Radxa Zero 3W**, hardware H.264 through `mpph264enc`, with a `control` datachannel alongside — [`remote-webrtc.md`](../design/remote-webrtc.md) §0. Ships in the release and runs as its own unit | -| `tof/` | `tofd`: the head's 8×8 ToF matrix, published on its own socket at 15 Hz. Read by `robotd`'s theremin and drawn by `robotctl monitor`; a board with no sensor fitted runs it anyway and says so | -| tests | **936 passing** on a Mac (`--exclude tof`), a few more on Linux — 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 | the app, the SDK, and reaching a robot from outside the LAN | -| 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: - -1. **Dev-channel installs** — install a specific branch or commit on a board, without - cutting a release. This is now ahead of `btd`: teammates will use `robotctl`, not the - phone app. -2. ~~**A repo and a dev signing key**~~ — **done.** `pollen-robotics/microduck` - (private), CI green on first fix; `team.dev` key generated. The signing secrets and the - `release` environment are in place too, and `0.2.0` went out through them. - -~~`btd` and the app path slip behind both.~~ Both landed early anyway — see M6 for why the -trigger turned out not to be the phone app. +| recovery | `robot-boot-check.timer` + `robot-rescue` + the `golden` symlink ship and are enabled. **Never exercised on a board** ([`boot-recovery-net.md`](../design/boot-recovery-net.md)) | +| tests | **942 passing** on a Mac with nothing excluded, a few more on Linux — including the health gate, the battery and thermal readout and the policy/safety path against a real `robotd` process, and `configd`'s authorisation over real sockets in `board-test.sh` | +| in flight | `maploc` (#127), the NPU duck detector, the chorale election fix (#151); two design PRs with nothing built — the phone app (#107) and the IPC monitor (#52) | +| missing | the app, the SDK, the model channel, the autonomous brain, and reaching a robot from outside the LAN | + +## The first roadmap reached its target + +M1 to M4 were one sequence with one destination: a robot that walks, updates itself over the +air, and cannot be bricked doing it. That arrived. Every release since `0.2.0` reached a board +through the machinery M1 and M2 built, and `0.9.x` is a duck that walks, rolls, sees, talks and +sings. + +What follows is not a continuation of that queue. The work left is independent tracks with +different risks and no ordering between them, so **the numbers below are identifiers, not a +sequence** — [the order of work](#the-order-of-work) is its own section. + +**The founding claim was half right.** *The hard part is productisation, not capability* judged +the difficulty correctly: the update path, the health gate, the recovery net and the BLE surface +all got built, and all hold. It judged the volume wrong. Nearly everything since `0.5` has been +capability — kinematics, odometry, ToF, gaze, the voice, the chorale, the camera — and the +largest single piece of it, the autonomous brain, is still unported. ## 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. - -- **`robotd` skeleton** — heartbeat plus the four `robot.*` methods `updaterd` calls. 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` / `--busy` exercise rollback on a bench robot. -- **`duck-ipc-proto` extracted** — `robotd` and `robotctl` depend on it and not on `updater`, - so nothing on the recovery path links the engine's http/tar/crypto tree. -- **The health gate is real** — `on_apply` restarts `robotd`, `health` is a socket probe, and - a test fails if either regresses to its inert bootstrap value. -- **One source of truth for the robotd socket** — `robot_socket` at the top level of the - config; `--robot-socket` is 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 survives `RUST_LOG=warn`; `robotctl version` reports - running *and* installed per service, because `updaterd` never 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::Ref`** and `manifest_at_ref` on the source trait. `--ref` conflicts with - `--version` rather than one silently winning. -- **`dev.yml`** — every branch push publishes `-dev..` to the moving tag - `daemon-dev-`, signed with `team.dev`. -- **`xtask package` accepts 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_dir`** too, 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, and - `version_under` refuses to read a dev tag as a release version. -- A dev build **cannot install on a customer robot** — `allow_dev_keys` is 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 `** and `scripts/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`, `--staging` and a return to the release stream. It is an ordinary - apply: health gate, auto-rollback, dev-key verification. `API_VERSION` moved 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 one - `scripts/dev-push.sh --bootstrap` to get there — the binary that would gate the update is the one - being replaced. +### M1 — Close the loop · **done** + +The updater got something real to gate against, and the team got a shared crate boundary: +a `robotd` skeleton whose state is atomics rather than a mutex (a robot whose loop is wedged +must still be able to answer *I am not healthy*), `duck-ipc-proto` extracted so nothing on the +recovery path links the engine's http/tar/crypto tree, a health gate that is a real socket +probe, one source of truth for the `robotd` socket, an identity line every daemon logs at +`warn` before anything can fail, and first-install bootstrap 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`. + +### M2 — Dev channel · **done** + +`sudo robotctl update apply daemon --ref my-branch` installs a branch build, and +`scripts/dev-push.sh` installs a laptop build over ssh with no CI run at all. Two properties +make that safe on every push, both enforced away from the workflow: a dev build **cannot become +`latest`** (the version is a semver prerelease and `version_under` refuses to read a dev tag as +a release version), and it **cannot install on a customer robot** (`allow_dev_keys` is false +there, and a trusted key only counts as a dev key if its filename ends `.dev.pub`). **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`](../design/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. +**One thing it left open, and it is now an M6 item rather than a footnote:** a private repo's +release assets are reachable with a token and a customer robot has none, so as things stand +**robots in the field cannot download anything**. [`updater-design.md`](../design/updater-design.md) +§6.1 lays out the four options and picks none. + +### M3 — `robotd` for real · **done**, in two slices -### M4 — Hardware bring-up · in progress +`robotd` **replaced** `microduck_runtime` by extracting its control core into `duck-control` +rather than reimplementing it, so parity arrived as a consequence of the extraction instead of +as a race against a moving target. Slice 1 was a real 50 Hz Dynamixel loop holding its pose — +which is what makes `robot.health` mean *the loop is meeting its deadline*. Slice 2 was one +61-D observation builder, the main-plus-standing policy, the intent surface, and a gamepad +client going through it. + +**Safety authority landed here rather than in M6**, 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. + +**Hardware first, sim after** — and the board 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. -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. +**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; a release that comes up +unhealthy reverts on its own. -**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. +### M4 — Hardware bring-up · **closing** -Also the first chance to settle the **log retention** question, which cannot be answered -off-board (`deploy/README.md`): +A measurement milestone, not a feature one: it exists to turn guesses into numbers on a real +Radxa. Nearly all of it was answered as a side effect of shipping — the loop holds 50.0 Hz on a +non-RT kernel (`missed=3` in 15022 ticks), the bus and the `imu_to_dxl` board answer on +`/dev/ttyS2`, thermals have a real reading across every zone, `systemctl restart` in `on_apply` +works against real systemd, and the gate commits and reverts for real. -- `findmnt /var/log` — if Armbian's RAM-log has it on tmpfs, journald's `Storage=persistent` - is a directory in memory: it survives a clean `reboot` and 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-boots` after a real reboot — two or more entries, or the drop-in is not - doing what it claims. +**The log-retention question was settled by deciding rather than measuring.** `/var/log` is a +zram device on this image, so `Storage=persistent` gets journald a directory that is itself in +memory: it survives a clean `reboot` and loses recent logs on a power cut. That is the intended +arrangement — the durable record is the update history under `/var/lib`, `fsync`ed per entry +([`deploy/README.md`](../../deploy/README.md)). So the original "logs survive a power cut" +criterion is retired: it asks for a property this project decided not to have. -**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. +**Done when:** it walks on hardware, an update applied via `robotctl` restarts `robotd` cleanly +with the gate passing, and `journalctl --list-boots` reports the previous boot after a reboot. +The first two are done; the last is one command on a board and is the only thing outstanding. -### M5 — `mediad`, WebRTC, SDK · in progress +**Two numbers deliberately left unmeasured**, because nothing is waiting on them: eMMC write +timing and battery under load. They belong to M7, which is the milestone about knowing what a +board is doing. -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. +### M5 — `mediad`, WebRTC, SDK · **in progress** **Landed, on hardware.** `mediad` ships in the release and runs as its own unit. The camera -reaches a browser on the LAN through the VPU — `mpph264enc` → `webrtcsink`, constrained baseline — -with a `control` datachannel carrying the same JSON-RPC every other transport speaks, and the -console is served by the robot itself so there is a URL and nothing to install -([`remote-webrtc.md`](../design/remote-webrtc.md) §0, [`webrtc-console.md`](../design/webrtc-console.md)). -Two GStreamer plugins had to be built from source to get there, which is its own record -([`media-bringup.md`](media-bringup.md)), and the camera has since got 3A through `rkaiq`. - -**Still open, and they are what keeps this milestone from closing:** reaching a robot from outside -the LAN — the design is the same one with a proxy in front (§7), deliberately built second — the -SDK, and the privacy pair. Consent and the streaming indicator are *not* done, and this is the -milestone that was supposed to stop them being bolted on, and the reason given for deferring them -is a hardware one — an LED under software control, which does not yet exist -([`remote-webrtc.md`](../design/remote-webrtc.md) §11). - -**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. +reaches a browser on the LAN through the VPU — `mpph264enc` → `webrtcsink`, constrained +baseline — with a `control` datachannel carrying the same JSON-RPC every other transport +speaks, and the console is served by the robot itself so there is a URL and nothing to install +([`remote-webrtc.md`](../design/remote-webrtc.md) §0, +[`webrtc-console.md`](../design/webrtc-console.md)). Two GStreamer plugins had to be built from +source to get there ([`media-bringup.md`](media-bringup.md)), and the camera has since got 3A +through `rkaiq`. + +**Three things keep it open.** + +**Outside the LAN.** The same design with a rendezvous service and TURN in front (§7), +deliberately built second. + +**The SDK, and a small Python client.** §5.3 designs it as WebSocket plus snapshot: the same +JSON-RPC, no media stack, `get_frame` returning a JPEG, a few dozen lines — and `mediad`'s +session layer was built so that surface reuses it unchanged (`mediad/src/session.rs`). A Python +client over **WebRTC** instead gets live video and the `control` datachannel from one +connection, at the cost of `aiortc`, an ICE negotiation and a signalling round trip for a caller +who only wants to send an intent and read a frame. **The investigation is whether one client +covers both** — WebSocket for control and snapshots, WebRTC only when the caller asks for a +stream — or whether the WebSocket surface alone is what a script wants and live video stays in +the console. Answer that before writing either, because it decides whether the SDK is fifty +lines or a project. + +**Privacy, and it is now two items rather than one.** *Consent* — explicit per-session approval +before a stream starts — is a `mediad` session-layer change and is not blocked on anything. The +*visible indicator* needs an LED under software control, which does not exist on this hardware +([`remote-webrtc.md`](../design/remote-webrtc.md) §11); it is a hardware question and it should +be asked of the hardware rather than parked on a software milestone. `architecture.md` §7 is +right that both are cheap now and expensive later, so consent should not wait for the LED. + +**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. - -**The update path over Bluetooth is now driven rather than merely routed**, which is what that -order was for: `update-over-ble.md` records what driving it from `duckctl` turned up, including -one defect that made "start an update and watch it" an update the robot silently never performed. -`update.rollback` and `update.select` are reachable from a phone as of that work. - -**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`, `padd`, `mediad` and `tofd` are all -siblings now — nothing is 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:** - -It ended up wider than this section first drew it — the libraries `robotd` drives are their own -crates now, for the reason `duck-control` was: the compiler is what keeps daemon concerns out of -them. The list lives in [CONTRIBUTING.md](../../CONTRIBUTING.md#the-layout) rather than here, -because it is reference and this page is a record. The shape: - -``` -duck-ipc-proto/ wire types — serde/serde_json/semver only; btd/robotd/robotctl depend - on this, never on updater -updater/ engine + updaterd -robotd/ control, gait, safety, the voice, the theremin, the chorale -duck-control/ the control core, extracted from the prototype runtime -kinematics/ odometry/ sounds/ pet-detect/ robotd-params/ - libraries robotd drives — no sockets, nothing systemd starts -configd/ wifi (NetworkManager), robot name, pairing PIN, reboot, gamepad pairing (BlueZ) -padd/ gamepad → intents; a client, with no privilege the app will not have -mediad/ camera, encode, WebRTC gateway, and the console it serves -tof/ tofd — the head's depth sensor, published on its own socket -btd/ BLE transport adapter -robotctl/ CLI -duckctl/ the client, from a laptop (dev tool, never shipped) -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. +What a stranger needs. Preorders are open and shipping is a few months out, so this is ordered +by **lead time** rather than by difficulty — the items with the least code have the longest +lead. + +- **Where a customer robot downloads from.** M2's open question, above: a private repo cannot + serve the fleet, and this changes where `release.yml` publishes and what `updater.toml` on + every robot points at. Longest lead of anything here, because a robot shipped pointing at the + wrong place is a robot that cannot be updated. +- **The pairing PIN.** BLE pairing security rests on a per-robot PIN; the factory default is + `000000` and 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 — a factory + process, not a patch. It cannot come from the identity, which is published in an + advertisement. +- **Calibration**, the other half of provisioning. Identity no longer waits on it: a robot + derives one from its own SoC serial and names itself `duck-c51b`. +- **The recovery net, exercised on a board.** It is built and shipped and has never run against + a release whose daemons cannot start, which is the only path that matters. Cheapest item here: + one board, one deliberately broken release. +- **Consent**, from M5. +- **Manifest staleness reporting** (§8.4.2). +- **Authority arbitration**, finished — including the edge #52 surfaced: `Call::is_mutating()` + does not cover `robot.enable`, so the call that starts a policy running on a walking robot is + classified alongside `hello`. +- **The app.** #107 designs it and builds nothing. The blocker is a phone spike — scan, connect, + `hello`, authenticate, `system.info` with `--require-pairing` on, on a real iPhone and a real + Android — because §5.5 is currently a fact about CoreBluetooth on a laptop. + +**Done when:** a non-developer updates the robot from the phone, and a deliberately bricked +release recovers without a laptop. + +### M7 — Knowing what a board is doing + +A duck in someone else's hands will develop a fault, and today the answer to *which part* is a +developer reading a journal over ssh. There is no shape for this yet; it is an open +investigation, and the questions are what it owns. + +**What exists already** is more than it looks: `robotctl health` reports the bus, the IMU, the +battery, per-motor temperatures and the SoC; `robotctl monitor` draws the loop, the pad stream, +the ToF matrix, the 3D robot and a power row; `scripts/pad-stack-report.sh` is a precedent for +collecting one subsystem's whole story into something a person can paste; `board-test.sh` runs +60 checks, but on emulated aarch64 in CI rather than on the board in front of you. + +**What is missing, as questions:** + +- **A support bundle.** One command, after a fault, producing a file someone can send. Nothing + collects health, versions, the journal, the update history and the unit states together. +- **A hardware pass/fail a non-developer can run.** Bus scan and a per-servo answer, IMU sanity, + ToF present, camera present, NPU present, mic and speaker, battery under load. Closer to + `board-test.sh` in spirit, aimed at the hardware rather than the release. +- **History, because intermittent faults are invisible in a snapshot.** Bus read failures, + missed ticks, servo temperature peaks, brownouts. The journal does not survive a power cut by + design (M4), so anything that matters here needs the update history's durability rather than + the journal's. +- **The overlap with #52.** The IPC monitor design is the software half of this same question, + and its crash record — the last events flushed to a fixed-size file under `/var/lib`, outside + release dirs so it survives update and rollback — is exactly the durability property a + hardware fault report needs. The two should be decided together rather than growing two + answers. + +M4's two unmeasured numbers land here: eMMC write timing and battery under load. + +**Done when:** someone who is not a developer can run one command after a fault and produce +something that names the part at fault, or says the hardware is fine. + +### M8 — The model channel: policies from the Hub + +Today every policy ships **inside the daemon artifact** — `robotd` loads +`.../current/policies/alpha_walking.onnx` and friends — so a new gait needs a daemon release, +and a policy trained on a laptop reaches a duck only through CI or a sideload of the whole +daemon. The point of this milestone is that a policy trained in +[`microduck_rl`](https://github.com/pollen-robotics/microduck_rl) can be published, installed, +tried and rolled back on its own version line. + +**The engine was designed for that arrangement and most of it is already built:** + +- the `hf_hub` source resolves `https://huggingface.co/{repo}/resolve/{revision}/{file}` and + verifies **our own** minisign signature, because HF signs nothing for us (§5.1); +- a model is an ordinary component — its own version line, install dir, rollback target, pin, + boot-counter trial and known-bad history. `robotctl update apply model-walk` and + `robotctl update select model-walk 1.1.0` work the moment one is configured (§5.5); +- `on_apply = { action = "reload", unit = "robotd", signal = "SIGHUP" }` is implemented in the + engine, so a weights swap does not have to restart motor control; +- `xtask sign` already signs any directory of artifacts. + +**What is missing is at the two ends, not in the middle:** + +- **`robotd` cannot reload.** There is no SIGHUP handler and no way to swap an `ort` session + under a running 50 Hz loop. This is the milestone's real engineering: the swap must not drop a + tick, and a model whose shape is not `obs[1,61] → actions[1,14]` has to be refused *before* it + goes live rather than at the first inference. +- **Nothing publishes a bundle.** `xtask package --channel model-walk` is close — it checks + `--version` against the crate version, which a model does not have — and the HF repo layout + and naming do not exist. +- **A third signing key.** `release-1` is CI's and `team.dev` installs nothing on a customer + robot, so *who may publish a policy a robot will run* is a new custody question, not a reuse + of an existing one. +- **`model_api`** (§5.5) is designed and unimplemented on both sides. +- **The training loop.** `microduck_rl` trains and exports to ONNX; nothing carries the result + to a board without a daemon release. The model equivalent of `dev-push.sh` is what makes + "train it and try it" a minute rather than a CI run. + +**One decision comes before all of it.** If policies move to the Hub, a freshly flashed board +with no network has no gait. Keeping the release's copies as the floor and letting a model +component override them is the obvious shape, but it means two sources for one file and a rule +about which wins; the alternative — models only from the Hub — makes the network a hard +dependency for a robot that walks. Note that this milestone does *not* inherit M6's +download problem: the Hub is public, and a model repo can be public whatever the source repo +does. + +**Done when:** a policy trained in `microduck_rl` is published to the Hub, installed on a duck +with `robotctl update apply model-walk`, and rolled back with `robotctl update select` — with +the control loop never dropping a tick through either. + +### M9 — The autonomous brain + +The biggest untracked gap: the runtime's `autonomous.rs` exists nowhere in the daemon and no +design doc owns it. [`ideas/autonomous_behavior.md`](../ideas/autonomous_behavior.md) is the +holding pen — a 16-state machine on an energy/mood model, novelty-grid exploration, ToF +avoidance, startle, sound reactions, ball play, a nap cycle, petting. + +Every input it needs now exists, and some it never had: ambient sound events, depth frames, +classified trunk-frame obstacle points, voice tags, nearby ducks by stable id, a shared beat, +RSSI as coarse distance, a live synth voice, hand distance from the ToF. + +**The shape matters more than the states.** Presence, mood and the beat are *inputs to one +brain*, not modes beside it. The chorale and the theremin grew as explicit modes because there +was no brain to hang them on — the chorale is 55 KB of `robotd` — and that is the pattern this +milestone exists to stop repeating. It gets a design doc before it gets code. + +**Done when:** a duck left alone in a room does something worth watching for ten minutes, and +the chorale is something it decides rather than something a command starts. + +## The order of work + +The numbers above are identifiers. This is the order. + +1. **M8, the model channel.** The next feature. Nothing else is blocked by it, and it unblocks + the loop that produces the robot's actual behaviour: train, publish, install, try, roll back. +2. **M5's transport investigation.** Cheap, and its answer decides whether the Python client is + fifty lines or a project. +3. **M6, as shipping approaches**, by lead time: where a customer robot downloads from, then the + PIN, then the recovery net's hardware test. Consent can land any time and should land early. +4. **M7** — an investigation with no date. It earns priority the first time a duck in someone + else's hands develops a fault nobody can name, and the cheapest way to be ready is to decide + it alongside #52 rather than after it. +5. **M9, the brain** — later, deliberately. It is the largest piece of work left and the one + most likely to grow while being built. ## Decisions that shape work rather than follow it -1. ~~**Signing key custody**~~ — **done.** Three encrypted release keys plus an - unencrypted dev key in `~/.duck-keys`, all round-trip verified; only `release-1` goes into - secrets. Releases are signed in CI under `environment: 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. See - [`ci-setup.md`](ci-setup.md). -2. **Safety authority** (§6) — pulled into M3 for the reason above. -3. **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`](../design/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 is `000000` and 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. -4. **Privacy** — consent + indicator in M5, not M6. +1. ~~**Signing key custody**~~ — **done** for the daemon. Three encrypted release keys plus an + unencrypted dev key in `~/.duck-keys`; only `release-1` goes into secrets. Releases are signed + in CI under `environment: release`, which scopes the secrets but **gates nothing** — no + required reviewers, no branch policy. 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. See + [`ci-setup.md`](ci-setup.md). **Reopens with M8**: publishing a policy is a third kind of key. +2. **Safety authority** (§6) — landed in M3. +3. **Provisioning** — identity is done; calibration and the PIN are not, and the PIN is a factory + process rather than code (M6). +4. **Privacy** — consent is M5 and unblocked; the indicator is a hardware question and should be + asked as one. +5. **Where a customer robot downloads from** — open, and the longest-lead decision in M6. + [`updater-design.md`](../design/updater-design.md) §6.1 has the options. +6. **Whether policies leave the daemon artifact** — open, and M8 cannot start without it. ## 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). +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). From b516798c5f3827a8e762d0ac73a5ab5bf531a4f4 Mon Sep 17 00:00:00 2001 From: Pierre Rouanet Date: Wed, 26 Aug 2026 17:41:06 +0200 Subject: [PATCH 2/4] Two decisions the rewrite was written just before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The repository goes public**, which closes §6.1. It was the longest-lead item in M6 on the grounds that a robot shipped pointing at the wrong host cannot be updated; going public means there is no second host and no token in an image. The section keeps the options and the reasoning rather than deleting them — an artifact-only public repo is the fallback if the source ever has to close again — and records what going public does *not* change: the engine's API path is already the one path for private and public alike. What it does leave is a budget, written down where someone will hit it. Anonymous GitHub API requests are capped at 60 an hour per IP and a home robot has no token, so its checks spend from the pool shared by everything behind that address. One duck at `check_interval = "6h"` is nowhere near it; a room of twenty on one wifi is not. Not a correctness problem — `http.rs` already reads 403 and 429 as "come back later" — but a reason to fetch bytes through `browser_download_url` once public, since object downloads spend nothing from that pool. **Models: browsing is a trust decision, not a plumbing one.** The ask is basic policies of our own plus an easy way to look at and download others, and the second half is what needs deciding: every artifact the engine installs is verified against a trusted key, and a stranger's model is signed by nobody this robot trusts. Recorded as three things to settle inside M8 — curated org or an explicitly unverified path that is off by default and refused on a customer robot the way dev keys are; the `obs[1,61] -> actions[1,14]` shape check becoming a hard gate whoever signed it; and the fact that makes it allowable at all, which is that the safety layer holds the only write handle to the bus. Slots stay fixed and sources do not: repointing `walk` at another repo keeps rollback, pin and known-bad per slot, where letting arbitrary components appear at runtime would mean the config is no longer the authority on what a robot may run. Assisted-by: Claude:claude-opus-5[1m] --- docs/design/updater-design.md | 50 +++++++++++++++++++--------------- docs/project/roadmap.md | 51 +++++++++++++++++++++++++++-------- docs/robot/install-dev.md | 5 ++-- 3 files changed, 72 insertions(+), 34 deletions(-) diff --git a/docs/design/updater-design.md b/docs/design/updater-design.md index 95856966..05bb3077 100644 --- a/docs/design/updater-design.md +++ b/docs/design/updater-design.md @@ -471,38 +471,46 @@ done only when genuinely needed, never unconditionally on every update. - CI side: `cargo-dist` can build and publish signed artifacts to GitHub Releases; we host manifests as additional assets. -### 6.1 ⚠ A private repository cannot serve the fleet +### 6.1 A private repository cannot serve the fleet — so this one goes public -**Unresolved, and it constrains M4.** `pollen-robotics/microduck` is private, and a -private repo's `releases/download//` URL returns **404 — with or without a -token**. Verified directly: +**Decided (2026-08-26): `pollen-robotics/microduck` becomes public.** Until it does, a robot +in the field cannot download anything, and the reason is worth keeping because it is not +obvious: a private repo's `releases/download//` URL returns **404 with or without +a token**. Verified directly: | URL | private repo | |---|---| | `https://github.com//releases/download//` | 404, authenticated or not | | `https://api.github.com/repos//releases/assets/` + `Accept: application/octet-stream` | 200 with a token | -The engine now resolves every asset through the API endpoint, so a **developer's board** -works: `GITHUB_TOKEN` is in the environment and `--ref` installs a branch build. Verified end -to end against this repo. +So the engine resolves every asset through the API endpoint, which works for a **developer's +board** — `GITHUB_TOKEN` is in the environment and `--ref` installs a branch build — and for a +public repo, but not for a customer robot, which has no token and should not have one: a +fleet-wide credential baked into an image leaks and cannot be rotated without reflashing, the +same problem the signing keys are tiered to avoid. -**A customer robot has no token, and should not.** A fleet-wide credential baked into an -image is a credential that leaks and cannot be rotated without reflashing — the same problem -the signing keys are tiered to avoid. So as things stand, robots in the field cannot download -anything. The options, none of them chosen yet: +Three other options were on the table, and are recorded because the decision could be revisited +if the source ever needs to close again: | option | keeps zero-backend | notes | |---|---|---| -| A **public repo holding only release artifacts** | yes | signatures are what make an artifact safe, not obscurity — a public artifact repo leaks build metadata and nothing else. Source stays private. | -| Make this repo public | yes | product source; presumably not. | -| An object store or CDN with a plain HTTP source | mostly | one more thing to own and pay for; the engine's source trait already abstracts it. | -| A read-only token in the image | yes | rejected reasoning above: an unrotatable fleet credential. | - -The first is the conventional answer and costs nothing but a second repository. It does not -change the engine — only `repo` in `updater.toml` and where `release.yml` publishes. - -Nothing about this blocks M2 or M3: dev boards have tokens and sim needs no downloads. It -blocks the first robot that has to update itself without a developer present, which is M4. +| A **public repo holding only release artifacts** | yes | signatures are what make an artifact safe, not obscurity — an artifact repo leaks build metadata and nothing else. Source stays private. The fallback if this repo ever goes private again. | +| An object store or CDN with a plain HTTP source | mostly | one more thing to own and pay for; the source trait already abstracts it. | +| A read-only token in the image | yes | rejected: an unrotatable fleet credential. | + +**Going public changes no code.** The API path stays correct — it is the one path for private +and public alike, which is why it was written that way. + +**What it does change is a budget.** Unauthenticated GitHub API requests are limited to 60 per +hour *per IP*, and a token lifts that; a robot in someone's home has no token, so its checks +spend from the anonymous pool shared by everything behind that address. At `check_interval = +"6h"` and a handful of calls per check, one duck is nowhere near it — a room of twenty on the +same wifi, updating together, can be. It is not a correctness problem: `http.rs` already reads +403 and 429 as "come back later" and says so in the message. It is a reason to prefer +`browser_download_url` for the bytes once the repo is public, since object downloads from +`objects.githubusercontent.com` spend nothing from that pool, and to keep the API path for +private repos and dev boards. Worth doing before a room full of ducks exists, not before the +first one ships. ## 7. `updaterd` state machine diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md index b37b3405..23137232 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -74,10 +74,11 @@ there, and a trusted key only counts as a dev key if its filename ends `.dev.pub **Done:** verified against the real repository — `dev.yml` published, `--ref main` installed over the network, and a customer-robot config refused the same build. -**One thing it left open, and it is now an M6 item rather than a footnote:** a private repo's -release assets are reachable with a token and a customer robot has none, so as things stand -**robots in the field cannot download anything**. [`updater-design.md`](../design/updater-design.md) -§6.1 lays out the four options and picks none. +**One thing it left open, now decided:** a private repo's release assets are reachable with a +token and a customer robot has none, so until this week robots in the field could not download +anything. **The repository goes public**, which resolves it and changes no code — the API path +the engine uses works for public repos too +([`updater-design.md`](../design/updater-design.md) §6.1). ### M3 — `robotd` for real · **done**, in two slices @@ -166,10 +167,6 @@ What a stranger needs. Preorders are open and shipping is a few months out, so t by **lead time** rather than by difficulty — the items with the least code have the longest lead. -- **Where a customer robot downloads from.** M2's open question, above: a private repo cannot - serve the fleet, and this changes where `release.yml` publishes and what `updater.toml` on - every robot points at. Longest lead of anything here, because a robot shipped pointing at the - wrong place is a robot that cannot be updated. - **The pairing PIN.** BLE pairing security rests on a per-robot PIN; the factory default is `000000` and 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 — a factory @@ -189,6 +186,12 @@ lead. `hello`, authenticate, `system.info` with `--require-pairing` on, on a real iPhone and a real Android — because §5.5 is currently a fact about CoreBluetooth on a laptop. +One item left M6 by being decided: where a customer robot downloads from. The repository goes +public, so a shipped robot reaches its releases with no token and no second host. The follow-up +it leaves is a budget rather than a blocker — anonymous GitHub API requests are capped at 60 an +hour per IP, which one duck is nowhere near and a room of twenty on one wifi is not +(§6.1). + **Done when:** a non-developer updates the robot from the phone, and a deliberately bricked release recovers without a laptop. @@ -263,6 +266,28 @@ tried and rolled back on its own version line. to a board without a daemon release. The model equivalent of `dev-push.sh` is what makes "train it and try it" a minute rather than a CI run. +**Looking at what other people have made** is the other half of the ask, and it lands on the +trust model rather than on the plumbing. Our own policies are basic and we sign them; a +stranger's is signed by nobody this robot trusts, and every artifact the engine installs is +verified against a trusted key. Three things to settle with the milestone rather than after it: + +- **Curated or open.** A model published into an org we sign for keeps every guarantee the + component system already gives — rollback, pin, known-bad, the health gate — and costs + nothing new. An open set needs an explicitly unverified install path: off by default, never + auto-applied, and refused on a customer robot the way `allow_dev_keys` already refuses dev + builds. +- **The shape gate stops being a nicety.** `obs[1,61] → actions[1,14]` has to be checked before + a model goes live whoever signed it, because an arbitrary policy drives fifteen servos. +- **What makes it survivable is already built.** The safety layer holds the only write handle to + the bus — joint clamps, fall → limp, an intent deadman — so a bad policy is bounded rather + than dangerous. That is the argument for allowing a stranger's model at all. + +**Slots stay fixed, sources do not.** `walk`, `stand`, `kick_left` and the rest are components +with their own version lines; "look for others" is a query over the Hub for models tagged for +this robot, plus repointing one slot's source at another repo. Letting arbitrary components +appear at runtime would mean the config is no longer the authority on what a robot may run, +which is the property the whole component design rests on. + **One decision comes before all of it.** If policies move to the Hub, a freshly flashed board with no network has no gait. Keeping the release's copies as the floor and letting a model component override them is the obvious shape, but it means two sources for one file and a rule @@ -273,7 +298,8 @@ does. **Done when:** a policy trained in `microduck_rl` is published to the Hub, installed on a duck with `robotctl update apply model-walk`, and rolled back with `robotctl update select` — with -the control loop never dropping a tick through either. +the control loop never dropping a tick through either — and someone who did not train it can +find it from the robot and try it. ### M9 — The autonomous brain @@ -323,9 +349,12 @@ The numbers above are identifiers. This is the order. process rather than code (M6). 4. **Privacy** — consent is M5 and unblocked; the indicator is a hardware question and should be asked as one. -5. **Where a customer robot downloads from** — open, and the longest-lead decision in M6. - [`updater-design.md`](../design/updater-design.md) §6.1 has the options. +5. ~~**Where a customer robot downloads from**~~ — **decided 2026-08-26:** the repository goes + public. §6.1 keeps the options and the reasoning, because they are the fallback if the source + ever has to close again. 6. **Whether policies leave the daemon artifact** — open, and M8 cannot start without it. +7. **Curated models or an open set** — open, and it is a trust decision rather than a plumbing + one (M8). ## Not doing, on purpose diff --git a/docs/robot/install-dev.md b/docs/robot/install-dev.md index ad1afd2a..5b80fba7 100644 --- a/docs/robot/install-dev.md +++ b/docs/robot/install-dev.md @@ -252,8 +252,9 @@ sudo systemctl daemon-reload sudo systemctl restart updaterd ``` -A token on a *developer's* board is fine. A token on a customer robot is not, and is why -artifact hosting is still an open question — see `docs/design/updater-design.md` §6.1. +A token on a *developer's* board is fine. A token on a customer robot is not, which is why the +repository is going public rather than shipping one — see `docs/design/updater-design.md` §6.1. +Until it does, a board with no token can still install from a local directory or a dev push. ## Installing without a network From 9e2167515dbf7aad51b6616b4d7dc9fd715c3d8a Mon Sep 17 00:00:00 2001 From: Pierre Rouanet Date: Wed, 26 Aug 2026 17:46:50 +0200 Subject: [PATCH 3/4] Policies leave the artifact, and a missing one is degraded rather than broken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lean is that they leave and the updater fetches them from the Hub. Written down with the edge that decision has, because it is not the obvious one: a policy that was wanted and could not be loaded reports *unhealthy*, so a board with no models installed would fail the health gate on every subsequent daemon update and roll back — an update loop caused by a file the update could not have supplied. `HealthResult::degraded` already answers it, and was built for this exact shape: a condition that belongs to the board rather than to the release being gated, where reverting cannot fix it and only churns the boot counter. Which model components are installed is that, so the gate commits and health names the missing policy. The other consequence — no gait on a flashed board with no network — goes where the same dependency already lives: provisioning installs the bootstrap set, as it already installs the ONNX runtime and the GStreamer plugins. That also leaves exactly one source for a policy at runtime, so there is no precedence rule to write between a release copy and a Hub copy. The floor-plus-override alternative stays recorded with what it buys, since the decision is a lean rather than a settlement. Assisted-by: Claude:claude-opus-5[1m] --- docs/project/roadmap.md | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md index 23137232..41318880 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -288,13 +288,33 @@ this robot, plus repointing one slot's source at another repo. Letting arbitrary appear at runtime would mean the config is no longer the authority on what a robot may run, which is the property the whole component design rests on. -**One decision comes before all of it.** If policies move to the Hub, a freshly flashed board -with no network has no gait. Keeping the release's copies as the floor and letting a model -component override them is the obvious shape, but it means two sources for one file and a rule -about which wins; the alternative — models only from the Hub — makes the network a hard -dependency for a robot that walks. Note that this milestone does *not* inherit M6's -download problem: the Hub is public, and a model repo can be public whatever the source repo -does. +**One decision comes before all of it, and the lean is that policies leave the artifact.** Two +things follow, and neither is a reason to reverse it: + +- A freshly flashed board with no network has no gait. +- The sharper one: `robotd` reports **unhealthy** when a policy it wanted could not be loaded + (`deploy/robotd.toml`, `[policy] enabled`), so on a board with no models installed every + subsequent daemon update would fail its health gate and roll back — an update loop caused by + a missing file the update could not have supplied. + +Both have answers that already exist: + +- **A missing model is `degraded`, not unhealthy.** `HealthResult::degraded` was built for + exactly this shape — a condition that is a property of the *board* rather than of the release + being gated, where reverting the daemon cannot fix it and only churns the boot counter. Which + model components are installed is precisely that, so the gate commits and `robotctl health` + says which policy is missing. +- **Provisioning installs the bootstrap set**, the way `setup-board.sh` already installs the + ONNX runtime and `setup-gstreamer.sh` the plugins. The network dependency lands where one + already exists, and at runtime there is exactly one source for a policy — the component's + install dir — with no precedence rule between a release copy and a Hub copy. + +The alternative — the release keeps its copies as a floor a Hub component overrides — buys a +duck that walks with no network at all, at the cost of two sources for one file and a rule about +which wins. It stays on the table if bootstrapping at provisioning turns out to be fragile. + +This milestone does *not* inherit M6's download problem: the Hub is public, whatever the source +repo does. **Done when:** a policy trained in `microduck_rl` is published to the Hub, installed on a duck with `robotctl update apply model-walk`, and rolled back with `robotctl update select` — with @@ -352,7 +372,9 @@ The numbers above are identifiers. This is the order. 5. ~~**Where a customer robot downloads from**~~ — **decided 2026-08-26:** the repository goes public. §6.1 keeps the options and the reasoning, because they are the fallback if the source ever has to close again. -6. **Whether policies leave the daemon artifact** — open, and M8 cannot start without it. +6. **Whether policies leave the daemon artifact** — **leaning yes**, not settled. They leave, a + missing model reports `degraded` rather than unhealthy so the gate commits, and provisioning + installs the bootstrap set. M8 says what the alternative buys if this turns out fragile. 7. **Curated models or an open set** — open, and it is a trust decision rather than a plumbing one (M8). From 4283e0ef4a476fb1f76cfb831f0c9706774b03c5 Mon Sep 17 00:00:00 2001 From: Pierre Rouanet Date: Thu, 27 Aug 2026 11:48:44 +0200 Subject: [PATCH 4/4] Say what was decided, not what is about to happen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything the rewrite added about going public was written in the future tense — "the repository goes public", "until it does", "until this week" — and all of it reads wrong the day the flip happens. The decision is what these docs record, so they now record the decision: *publish this repository*, phrased so it is true on both sides of the switch and needs no second pass afterwards. §6.1's heading loses its "so this one goes public" for the same reason. `install-dev.md` had the sharper version of the problem: it walks a reader through baking a GitHub token onto a board and `chmod 600`-ing it, listed as a flat prerequisite. Once the repo is public that credential buys nothing but a higher rate limit, and a reader following the steps unchanged would put one on a board for no reason. The prerequisite and the by-hand section now say when a token is actually needed — while the repo is private, or for the anonymous API budget §6.1 documents. `provision-board.sh` already said this ("only while the repository is private"); the docs were behind the script. Two smaller things a public reader would hit: - The link to `microduck_rl` 404s for anyone outside the org, and it is M8's central artifact. De-linked and named as private, rather than pointing a stranger at a page they cannot open. - M6 no longer carries a delivery estimate. "Preorders are open" is the whole reason the section orders by lead time; "shipping is a few months out" is a schedule commitment in a versioned file, doing no work in the argument. Not touched here, because it is a decision rather than an edit: `ci-setup.md` states in bold that anyone with push access can read the release signing key, and why no protection rule can be turned on. The roadmap links to it. That goes public in the same flip and is worth settling first. Assisted-by: Claude:claude-opus-5[1m] --- docs/design/updater-design.md | 10 +++++----- docs/project/roadmap.md | 33 ++++++++++++++++----------------- docs/robot/install-dev.md | 15 +++++++++------ 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/docs/design/updater-design.md b/docs/design/updater-design.md index 05bb3077..b60187d8 100644 --- a/docs/design/updater-design.md +++ b/docs/design/updater-design.md @@ -471,12 +471,12 @@ done only when genuinely needed, never unconditionally on every update. - CI side: `cargo-dist` can build and publish signed artifacts to GitHub Releases; we host manifests as additional assets. -### 6.1 A private repository cannot serve the fleet — so this one goes public +### 6.1 A private repository cannot serve the fleet — so this one does not stay private -**Decided (2026-08-26): `pollen-robotics/microduck` becomes public.** Until it does, a robot -in the field cannot download anything, and the reason is worth keeping because it is not -obvious: a private repo's `releases/download//` URL returns **404 with or without -a token**. Verified directly: +**Decided (2026-08-26): publish `pollen-robotics/microduck`.** While it is private a robot in +the field cannot download anything, and the reason is worth keeping because it is not obvious: +a private repo's `releases/download//` URL returns **404 with or without a token**. +Verified directly: | URL | private repo | |---|---| diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md index 41318880..1186c55d 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -75,9 +75,9 @@ there, and a trusted key only counts as a dev key if its filename ends `.dev.pub over the network, and a customer-robot config refused the same build. **One thing it left open, now decided:** a private repo's release assets are reachable with a -token and a customer robot has none, so until this week robots in the field could not download -anything. **The repository goes public**, which resolves it and changes no code — the API path -the engine uses works for public repos too +token and a customer robot has none, so while this repository is private robots in the field +cannot download anything. **The decision is to publish it**, which resolves that and changes no +code — the API path the engine uses works for public repos too ([`updater-design.md`](../design/updater-design.md) §6.1). ### M3 — `robotd` for real · **done**, in two slices @@ -163,9 +163,8 @@ frame and send an intent in a few dozen lines. ### M6 — Ship readiness -What a stranger needs. Preorders are open and shipping is a few months out, so this is ordered -by **lead time** rather than by difficulty — the items with the least code have the longest -lead. +What a stranger needs. Preorders are open, so this is ordered by **lead time** rather than by +difficulty — the items with the least code have the longest lead. - **The pairing PIN.** BLE pairing security rests on a per-robot PIN; the factory default is `000000` and public in this repository, so out of the box pairing proves physical presence and @@ -186,11 +185,11 @@ lead. `hello`, authenticate, `system.info` with `--require-pairing` on, on a real iPhone and a real Android — because §5.5 is currently a fact about CoreBluetooth on a laptop. -One item left M6 by being decided: where a customer robot downloads from. The repository goes -public, so a shipped robot reaches its releases with no token and no second host. The follow-up -it leaves is a budget rather than a blocker — anonymous GitHub API requests are capped at 60 an -hour per IP, which one duck is nowhere near and a room of twenty on one wifi is not -(§6.1). +One item left M6 by being decided: where a customer robot downloads from. The decision is to +publish this repository, so a shipped robot reaches its releases with no token and no second +host. The follow-up it leaves is a budget rather than a blocker — anonymous GitHub API requests +are capped at 60 an hour per IP, which one duck is nowhere near and a room of twenty on one wifi +is not (§6.1). **Done when:** a non-developer updates the robot from the phone, and a deliberately bricked release recovers without a laptop. @@ -234,9 +233,9 @@ something that names the part at fault, or says the hardware is fine. Today every policy ships **inside the daemon artifact** — `robotd` loads `.../current/policies/alpha_walking.onnx` and friends — so a new gait needs a daemon release, and a policy trained on a laptop reaches a duck only through CI or a sideload of the whole -daemon. The point of this milestone is that a policy trained in -[`microduck_rl`](https://github.com/pollen-robotics/microduck_rl) can be published, installed, -tried and rolled back on its own version line. +daemon. The point of this milestone is that a policy trained in `microduck_rl` — the training +repository, which is private — can be published, installed, tried and rolled back on its own +version line. **The engine was designed for that arrangement and most of it is already built:** @@ -369,9 +368,9 @@ The numbers above are identifiers. This is the order. process rather than code (M6). 4. **Privacy** — consent is M5 and unblocked; the indicator is a hardware question and should be asked as one. -5. ~~**Where a customer robot downloads from**~~ — **decided 2026-08-26:** the repository goes - public. §6.1 keeps the options and the reasoning, because they are the fallback if the source - ever has to close again. +5. ~~**Where a customer robot downloads from**~~ — **decided 2026-08-26:** publish this + repository, and a robot downloads from it directly. §6.1 keeps the other options and the + reasoning, because they are the fallback if the source ever has to close again. 6. **Whether policies leave the daemon artifact** — **leaning yes**, not settled. They leave, a missing model reports `degraded` rather than unhealthy so the gate commits, and provisioning installs the bootstrap set. M8 says what the alternative buys if this turns out fragile. diff --git a/docs/robot/install-dev.md b/docs/robot/install-dev.md index 5b80fba7..e7d73f3f 100644 --- a/docs/robot/install-dev.md +++ b/docs/robot/install-dev.md @@ -35,8 +35,9 @@ ssh-copy-id radxa@192.168.1.42 advertising yet. - **ssh key access**, from the step above. Provisioning reboots the board and reconnects by itself, and a password prompt cannot survive that. -- A **GitHub token**. This repository is private, so its release assets are unreachable without - one. +- A **GitHub token**, while this repository is private: its release assets are unreachable + without one. Once it is public the token is optional and buys only a higher API rate limit + (`docs/design/updater-design.md` §6.1). - A **clone of this repo**. The dev key it needs is committed at `deploy/dev-key/team.dev.pub`, so there is nothing to ask anyone for. @@ -220,7 +221,8 @@ sudo systemctl restart updaterd ## The token, by hand `scripts/install.sh` writes this for you when given `DUCK_TOKEN`. These steps are for a board -provisioned some other way. +provisioned some other way — and they are only needed while this repository is private, or on a +board that fetches often enough to want the higher rate limit a token buys. `updaterd` reads `GITHUB_TOKEN` from its own environment, so exporting it in your shell does not reach the daemon — it needs a systemd drop-in. @@ -252,9 +254,10 @@ sudo systemctl daemon-reload sudo systemctl restart updaterd ``` -A token on a *developer's* board is fine. A token on a customer robot is not, which is why the -repository is going public rather than shipping one — see `docs/design/updater-design.md` §6.1. -Until it does, a board with no token can still install from a local directory or a dev push. +A token on a *developer's* board is fine. A token on a customer robot is not — a fleet-wide +credential in an image cannot be rotated without reflashing — which is why the answer is a +public repository rather than a shipped token (`docs/design/updater-design.md` §6.1). A board +with no token can still install from a local directory or a dev push. ## Installing without a network