§4 described both layers as future work; the first one landed in the previous
commit. Notes what it actually does, and the three details that each cost a
debugging cycle — the cgroup trap in restarting your own parent, the fork
duplicating other engines' file locks, and gating on `on_apply` so the bootstrap
install is untouched.
Assisted-by: Claude:claude-opus-5
The exclusion protects the in-flight update and nothing else, and §4 read as though
it protected more. A new `updaterd` that cannot start is discovered at the next
boot — after the commit, with nobody watching, and with recovery living inside the
process that is failing to start. systemd crash-loops it and gives up, leaving a
robot with no update daemon and no way to update out of it.
Records the two layers that would close it, and what each catches: a read-only
self-test before committing plus a detached restart after the reply is sent, and a
boot-time `OnFailure=` net outside `updaterd` that swaps to golden.
Written down with the constraints rather than as a TODO, because each one is a trap
the obvious implementation falls into: `--check-only` is not read-only and would
have a second engine reverting the update in progress; restarting inside the RPC
hands the client a broken pipe instead of its outcome; a rescue that parses the
config cannot survive a release that breaks the config parser; and a blanket
"any daemon failed" reverts good releases on a board whose radio has not appeared.
No code. The self-test lands next; the boot-time net is its own PR.
Assisted-by: Claude:claude-opus-5
`roadmap.md` was still describing a repository where nothing had met a robot. Every row of
"where we are" that said so is now false, and a status doc that is wrong in the optimistic
direction is bad, but one wrong in the *pessimistic* direction is worse: it is the first
thing someone arriving reads, and it was telling them the walking, provisioning, releasing
system in front of them does not exist.
Corrected against what actually happened rather than what was planned:
- `duck-control` and `robotd` are no longer "untested on a board" — it walks, driven through
the intent API. M3's three done-when conditions all hold, so M3 is done.
- `.github` no longer says release/promote are unrun. Both ran on 2026-08-05: `0.2.0` to
staging, verified through the engine, installed on the board, promoted byte-identical.
- The "never run on hardware" row is replaced by two rows that separate what hardware has
settled from what it has not. The second one is M4's remaining reason to exist: thermals,
eMMC, battery under load, log survival across a power cut, and whether the 30s health-gate
timeout has margin on a cold boot.
- `robotctl` is no longer "the update namespace"; `padd` was missing from the crate layout
entirely; 403 tests are 425.
- The signing-key decision claimed releases are signed "behind an approval gate". They are
not: `ci-setup.md` says plainly that `environment: release` scopes secrets and gates
nothing on this plan. A doc claiming a control that does not exist is worse than one
admitting the gap, so it now says what is actually true and why it was accepted.
`updater-design.md` §16.3 said canary robots auto-pull staging. They cannot: `newest_version`
skips GitHub prereleases and semver prereleases alike, with no opt-out. That filter is what
keeps a customer robot off candidate builds, so the code is right and the sentence was wrong —
it now gives the `--version` command that does work. Found by a board reporting "no releases
with tag prefix daemon-staging-v" against a staging release that was sitting right there.
Deliberately untouched: `README.md`, `slice-2-bringup.md` and `policies/README.md` carry the
same drift, and #31 and #32 are both editing them. They follow once those land.
Assisted-by: Claude:claude-opus-5
`on_apply`'s unit list lived in `/etc/robot/updater.toml` — the operator's file,
which `install.sh` preserves on purpose. A board provisioned before a daemon existed
kept a list that never mentioned it, so every release swapped that daemon's binary
and left the old process running. The update reported success, the daemon answered
on stale code, and `apply` then said `already_current` and did nothing, so the
obvious recovery command was a no-op.
That shape cost an afternoon: four correct wifi fixes were diagnosed as broken
against binaries that were never running (`install-path-gap.md` §4).
A release already states which units it provides — it ships them in `systemd/` — so
it can say which to restart. Same realisation that made `hooks/postinstall` the
right place to install them. `units_to_restart` reads the active release's
`systemd/*.service`, unions the configured list, sorts, dedups, and drops the
exclusions.
The configured list stays as an *addition*, for a unit the release does not ship. It
is no longer load-bearing: a board whose config is years out of date now gets the
right behaviour with no edit.
`updaterd` and `btd` move from a config convention enforced by a test into a
`NEVER_RESTART` constant. They are properties of what those daemons are — one is
performing the update, the other may be the transport it arrived over — not choices
an operator should be able to get wrong, and getting them wrong breaks only while an
update is already running.
A release with no `systemd/` directory yields the configured list unchanged, so a
rollback to a release predating the directory still works.
Five tests, and the exclusion one was verified by deleting the `retain` and watching
it fail rather than by reading it.
Assisted-by: Claude:claude-opus-5
* board-test: exercise the binaries that ship, not an example that doesn't
The playground was a second implementation of the update system — publisher and
robot in one binary — written from a premise its own module doc still stated:
that `updaterd`'s socket server and `robotctl`'s transport "are not wired up
yet, so this drives `Engine` directly". They are wired up, and board-test.sh
drove them over a real unix socket forty lines below the checks that didn't.
So the board test asserted install, unhealthy rollback, tamper refusal and
boot-counter recovery against `updater/examples/playground` — which `xtask
package` never puts in a release artifact. Every one of those checks covered a
binary no robot will ever run, in a script whose stated job is to "exercise the
result on real ARM64 Linux".
The shipped daemon already has every piece needed: `install --from` for the
local-dir install a bare board does, `--inject-fault` for the failure paths,
`--check-only` for what a boot does. The only thing neither binary does is mint
a signed release to feed them, so that — and only that — becomes a
`test-support` example, reusing the same `Publisher` the tests publish with
rather than the playground's third hand-rolled copy of it (which had drifted:
no fixed tar mtime, the reproducibility property xtask relies on).
The fixture is minted host-native and mounted into the container: a release is
signed manifests and tarballs, no more architecture-dependent than the ones
GitHub serves. Releases are staged into `published/` one at a time, so what
`latest` resolves to is explicit at every step rather than a side effect of
publish order.
Two stale claims fixed while here: updater-design.md said release.yml verifies
via "`LocalDir` + `playground apply`" when it uses `updaterd install --from`,
and the README walkthrough is now the real daemon and CLI.
Assisted-by: Claude:claude-opus-5
* README: document the real update paths, drop the synthetic walkthrough
The "see the engine work" walkthrough was ceremony: mint fake releases, copy
one into a directory, start a daemon with a rigged health gate, drive it from a
second terminal. Six commands to watch a rollback that `updater/tests/apply.rs`
already asserts, and nothing a reader would ever type again.
What was actually missing from the README was how you install without a
network. `updaterd install --from <dir>` is the factory and sideload path, and
`--force` is the one escape hatch for the case that has already come up on a
board: an installed `updaterd` too old to accept the release that fixes it
being too old, rolling the new release back every time, with the binary running
that gate being the one to be replaced. Both were documented only in
`--help` text nobody reads before they need it.
`fake-release` stays — `scripts/board-test.sh` mints its fixtures with it. It
is just not something to put in front of someone reading the README.
Test count corrected to 319 while in that paragraph.
Assisted-by: Claude:claude-opus-5
The same rationale was living in three places — a code comment, a test comment,
and the design doc — so it was going to drift, and each copy had grown into an
essay. Applied one rule: code says what a reader needs at that line, a test says
which failure it prevents, and the doc holds the reasoning once.
Cut throughout: archaeology ("this used to be duplicated", "previously logged and
never enforced", "the first version derived the path from current_exe"), and
first-person notes about how a bug was found. Those belong in git history, which
already has them. What explains a non-obvious decision stayed, and in a few places
got shorter without losing anything: the `to` field's meaning per outcome, the
forced overrides during a first install, the two-tier peer policy.
docs/roadmap.md lost 87 lines: completed milestones had become a development
diary, and its job is what happens next. M1 and M2 now state what landed, what is
verified, and what is still open. updater-design.md lost the same style of
narrative from §8.1 and §16.2.
The startup-identity doc comment was 17 lines duplicated verbatim in both
daemons; now six, pointing at architecture.md §8 for the reasoning.
Resolved rather than reworded: the update lock's test carried a 13-line note about
an unreproducible flake, a bounded retry that masked it, and a TODO guessing at
the cause. Replaced with a strict assertion that distinguishes "still held" from
"could not open" — 72 runs (60 lib, 12 full workspace) with no failure, so the
retry was masking nothing, and a recurrence will now say which mode failed.
Also extracted `split_release_url` from the github source: its test had been
reimplementing the prefix logic instead of calling it, which is why it needed a
`let _ = s;` to compile.
Assisted-by: Claude:claude-opus-5
Six references: the README title, the installer's default repo and the curl
one-liner that documents it (twice), and two prose mentions in the design docs.
`microduck_runtime` and `microduck_brain` are left alone — those are the
prototypes, and were always spelled correctly.
Not renamed, because they say "duck" rather than "miniduck": the duck-ipc-proto
crate, the DUCK_* build and installer variables, and ~/.duck-keys. Worth a separate
decision rather than sweeping them in with a typo fix.
Already-published manifests embed the old URL and are signed, so they cannot be
edited — those releases have to be republished. Deliberate: nothing consumes them
yet.
Assisted-by: Claude:claude-opus-5
A private repository's `releases/download/<tag>/<asset>` URL returns 404 with or
without a token; the release-asset API endpoint returns the bytes given
`Accept: application/octet-stream`. Both verified against this repo. The engine
used the browser URL for the manifest, the artifact and their signatures, so
nothing could be fetched from this repository at all — `--ref main` failed at the
first request.
Assets now resolve through the API. A manifest's own url is rewritten only when it
names *our* repo: the manifest is untrusted at that point, since its signature is
checked after the bytes arrive, so a manifest naming another repository must be
fetched verbatim rather than turned into an authenticated API request against
that repo. Anything not matching is left alone, so a manifest pointing at a CDN
keeps working — the bytes are verified by hash and signature either way.
download_to takes an Accept header for the same reason: the asset API serves JSON
metadata unless asked for octet-stream, which would write a JSON blob under an
artifact's name and fail its hash check with no hint as to why.
This fixes dev boards, where a developer has a token, and thereby surfaces that a
customer robot — which has none, and should not carry a fleet credential — cannot
download from a private repo at all. Recorded as §6.1 with the options; it needs a
decision before M4, and a public artifact-only repository is the cheap answer.
Assisted-by: Claude:claude-opus-5
Was Trixie, Noble and Bookworm; now Debian 13 (Trixie) alone. Armbian offers the
others for this board, but we ship Trixie, and proving the binaries work on
configurations nobody runs cost roughly double the job time to defend a claim
nobody needs. BOARD_IMAGES= overrides it for a one-off check.
The glibc floor stays pinned at 2.31, with the reasoning corrected: it was
written as "below all three userlands", which no longer means anything. What the
pin actually defends against is the build host — unpinned, the build links
against whatever glibc the dev machine or CI runner has, which is newer than the
board's, so it links cleanly and then fails to load on the robot with nothing in
the output pointing at the cause.
Assisted-by: Claude:claude-opus-5
The update system and the minimum around it needed to ship itself.
updater/ config-driven engine: signature + hash verification, versioned
release dirs with an atomic symlink swap, health gate with
auto-rollback, per-component boot counter, golden release,
GitHub/HF/local sources, JSON-RPC over a unix socket with
SO_PEERCRED authorization, and `updaterd install` for a robot's
first release through that same engine.
robot-proto/ the wire contract. serde/serde_json/semver only, so nothing on
the recovery path inherits the engine's http/tar/crypto tree.
robotd/ skeleton: heartbeat plus the four robot.* methods updaterd needs
to decide whether an update is safe and whether it worked.
robotctl/ the local CLI, over robot-proto rather than over updater.
xtask/ package, sign, promote, keygen. Promotion re-signs a manifest
over identical bytes rather than rebuilding.
deploy/ the config a client robot ships with, the trust anchor, and
journal retention.
docs/ architecture, the update design, the roadmap, CI setup.
229 tests. The ones worth naming cover what is hardest to verify by hand:
rollback across four independent triggers, power loss mid-swap, and the health
gate against a real robotd process over a real socket.
Assisted-by: Claude:claude-opus-5