The policy channel: policies from the Hub, and skills from config - #191
Open
pierre-rouanet wants to merge 47 commits into
Open
The policy channel: policies from the Hub, and skills from config#191pierre-rouanet wants to merge 47 commits into
pierre-rouanet wants to merge 47 commits into
Conversation
… guessing Three questions were open every time policies-on-the-Hub came up: whether a policy that is not ours may run at all, what "try one" does to a robot that then reboots, and how many components nine files trained together should be. They are answered in docs/design/policy-channel-design.md — origin decides trust (pollen-robotics is official, everything else is community, local paths are neither), load and reset are a config edit plus a live reload rather than a new persistence mechanism, and the set ships as one component because it is trained as one. Two design pages disagreed with that and now point at it instead. robotd-design §2.3 keeps what a policy is and how it is validated; which file fills a slot is no longer implicitly its business. updater-design §5.5 keeps the general rule — a component is a thing with its own version line — and says the control policies took the other branch of it, so the model-walk/model-jump sketch reads as illustration rather than as the plan. Nothing behavioural. robotd-design's description of where policy files come from is left alone because it is still true: they do come from the release directory, and slice 2 is what changes that. Assisted-by: Claude:claude-opus-5[1m]
Swapping the network a duck walks with meant editing /etc/robot/robotd.toml and
restarting robotd. That is the loop anyone iterating on a gait runs dozens of
times a day, and every step of it is a chance to leave a robot configured for a
file that is no longer there.
`robotctl policy load walk <file>` does it live: the robot goes home, loads,
and drives again, over robot.loadPolicy on the same home-pose path robot.setMode
has used since v15. `policy list` says what each slot is running and whether it
came from the release or from somebody's home directory. `policy reset` takes
one slot back, and with no argument takes all seven — which is the "put it back
the way it came" the whole thing needed to be safe to play with.
Load and reset are config edits, not a second mechanism: [policy] walk is
already an Option<PathBuf> where unset means "the mode's default", so load
writes that key through the same toml_edit editor `configure` uses and reset
removes it. Comments survive, the choice survives a reboot, and there is one
answer to what the robot is running rather than two.
Three refusals and a fallback, which is what makes it safe rather than merely
convenient:
- The file is opened and shape-checked before the daemon accepts, so a 51-D
policy is refused while the robot is still walking on the old one, with the
two widths in the message.
- A load that fails at the home pose anyway keeps the controller that was
running. A mode switch that will not load leaves the robot unhealthy,
because the release it shipped in is broken; a trial is somebody trying a
file, and it must not be able to cost them the gait they had.
- A robot lying limp is not stood up to load a file. The ramp exists to stop
a swap under a moving gait, and there is no gait to protect on a bench.
- An override that will not load at the *next* boot costs its slot, not the
robot: the slot falls back to the release's own policy and health reports
degraded. Unhealthy would have been worse than the bug it describes — the
update gate would roll back every daemon release that followed, over a
stale config line no update could ever have fixed.
The one that could have been quiet and total: a board with no ONNX Runtime
fails every path equally, so a fallback that trusted each failure would strip a
robot's whole policy configuration because a library was missing. Only errors
that name a file are evidence about that file, and PolicyError::path is now the
thing that says which do.
API_VERSION 16 -> 17. robotctl and btd ship in the same artifact as robotd so
they move together; an older client gets METHOD_NOT_FOUND, which is the designed
skew. Both methods are refused over BLE and loadPolicy over WebRTC as well —
deferrals rather than rules, and route.rs says which.
Assisted-by: Claude:claude-opus-5[1m]
…it had Reported from a board: `robotctl policy reset` on an untouched robot went through the whole sequence — home, load seven networks, drive again — and arrived at exactly the policy it started with. Ten seconds of visible activity to reach the state it was already in reads as a fault, and it is the command somebody types when they are *not* sure what they changed, so it is the one most often already true. robot.loadPolicy now answers that as an acceptance that queued nothing, which is what robot.setMode has always done with "already in that mode": the caller asked for a state and has it. IntentResult::already carries the sentence saying which, so a client can tell an acceptance with work behind it from one without and stop waiting for a change that is not coming. The slot that must never take this path is one carrying an error. An override that failed at boot was dropped in memory, so it reads as not-overridden and running the default — indistinguishable from a slot nobody touched. Skipping that reset would leave the error standing and the config line still causing it at every boot, which is the case reset exists for. robotctl now plans the config edit before it asks the daemon anything, which falls out of the same question. Nothing to write means no root is needed, so a reset that is purely a query stops demanding sudo; and a write that would fail still fails before the robot moves rather than after. It writes even when the daemon had nothing to do, because the two can legitimately disagree: a key hand-edited and never restarted into leaves the file naming an override the robot is not running, and this is the command that reconciles them. `resetting_every_slot_is_accepted` asserted the old behaviour and is gone; both halves of what it covered — the shape is accepted, and it reaches the loop when there is work — are in the pair that replaced it. Assisted-by: Claude:claude-opus-5[1m]
…e instead robotd read its policies out of /opt/robot/daemon/current/policies, which is what made a gait retrain need a daemon release and a daemon fix re-ship six megabytes of unchanged weights. It now reads /opt/robot/policies/current, and that is the whole point: one runtime source for a policy, whoever put it there, with no precedence rule between a release copy and anything else to get wrong later. Nothing publishes a policy bundle yet, so until something does the release is what fills that directory. scripts/seed-policies.sh — run by hooks/postinstall on every update and by install.sh on a fresh board, which between them are every way policies reach a robot today — copies the .onnx files the release still carries into releases/seed-<version>/ and points `current` at it, in the same releases/ + symlink layout the updater already swaps. One rule makes that a bootstrap rather than a second home: it never touches a set it did not install. A `current` pointing anywhere but a seed-* directory means something else put policies there, and the release stops overwriting them for good. The handover therefore needs no flag and nothing to remember — whatever ends up publishing bundles just installs one, and the seeding is over. A newer seed does replace an older one, because while the release is the only source of policies a daemon update is still how a retrained gait reaches a board; a seeder that only ever filled an empty directory would freeze every board on whatever first seeded it. Two bugs found by testing the seeder rather than reasoning about it. The `current` symlink was written with an absolute target, which resolves against the wrong directory the moment the root is not the one it was written with — it is now relative to its own directory, the way the updater writes it. And the "is this ours" test was matching the absolute form, so with the fix it would have stopped recognising its own seeds and never replaced one. hooks/postinstall was not in the shellcheck list while being the one file that runs on every board on every update — the argument that comment opens with, applied to itself. It and seed-policies.sh are now linted. What is NOT here, and why: the policies component on the Hub. Every artifact the component path installs is signature-verified, unconditionally, by the same code that installs daemon binaries — so an official set delivered that way has to be signed, which is a decision about the engine rather than about policies and is not one to take while nothing is published. Recorded as the open question in policy-channel-design.md §13; the seeding holds until it is answered, and policies/ stays in the repo because the seeding is what reads it. Assisted-by: Claude:claude-opus-5[1m]
Seeding names its directory after the release, and on a bench board every dev push is a release: `seed-0.10.0-dev.752.cb507c8` and one more for each push after it, seven megabytes apiece, in a directory nothing prunes. That is the kind of growth noticed as a full eMMC months later rather than as the thing that caused it. Seen in the seed directory on a real board, not reasoned about. Older seeds are now removed, keeping the current one and the one it replaced — by name, from `$live` and `$target`, so there is no mtime guessing and nothing outside `releases/seed-*` is ever a candidate. A set this script did not install is still not this script's to delete, pruning included, and there is a test saying so. The previous seed is kept rather than pruning to one, because rollback does not run hooks: `post_swap` is on the apply path only, so reverting the daemon no longer reverts its policies. A release rolled back *because a policy in it was bad* therefore leaves that policy running. The ordinary way back is a forward `update apply daemon --version <older>`, which does run the hook and reseeds; the kept seed is what makes pointing `current` back by hand possible when that is not available. That policies stop rolling back with the daemon is the intended shape — two things with their own version lines do not revert together — but it used to be free and now is not, so it is written down in the design rather than discovered. It is only sharp while the release remains the only source of policies, which is one more reason not to leave that state sitting. Assisted-by: Claude:claude-opus-5[1m]
…n release
Six megabytes of unchanged weights rode along in every daemon artifact so that
postinstall could copy them into place. The copy step was never the point — the
directory outside the release was — and a board already fetches its other two
prerequisites the same way: setup-board.sh gets ONNX Runtime, setup-gstreamer.sh
gets the plugins, and neither is in the release either.
seed-policies.sh now fetches the pinned set from the Hub into
releases/seed-<pin>/ and points current at it. The pin lives in
[workspace.metadata.policies] and as literals in the script, with a test
asserting the two agree — the same trap setup-gstreamer.sh carries, because a
script that runs from inside a release cannot read the manifest. Bumping the pin
is now the whole of shipping a new gait: no daemon release, no restart, and a
board takes it at its next update.
Three properties, each of which is a way this could have gone wrong:
- An unchanged pin touches no network at all. postinstall runs under a
120-second hook timeout and a hook that times out rolls the update back, so
re-downloading seven megabytes on every unrelated daemon update would have
been spending that budget to arrive at the same bytes. The per-file curl
limits are sized against the same 120 seconds.
- Nothing partial goes live. The set lands in a staging directory and current
moves only once every file has arrived.
- A failed fetch keeps what is already installed. Found by a test rather than
by thinking: a half-published revision was downgrading a working Hub set to
the release's older copy, which is a regression dressed up as a fallback.
The fallback is now only for a board with nothing at all.
The download list is asserted equal to what robotd-params resolves across both
drive modes, so a file the script fetches and no slot wants is caught as dead
weight, and one a slot defaults to and the script misses is caught before it is
a degraded board.
Still transitional: policies/ ships, and a first install that cannot reach the
Hub falls back on it. That branch, the directory, the --include lines at three
sites and every_policy_in_the_repo_is_packaged all go once
pollen-robotics/microduck-policies exists and the fetch is proved on a board.
It is here so that proving it cannot leave a duck that will not walk.
Assisted-by: Claude:claude-opus-5[1m]
… release The set is on the Hub at pollen-robotics/microduck-policies, so the copies here have nothing left to do. Verified before deleting: the real fetch pulls all nine files and every one is byte-identical to what was vendored. Gone with them: the nine --include lines at each of the three packaging sites, and every_policy_in_the_repo_is_packaged, which existed to keep those three lists honest against this directory and now guards nothing. The transitional fallback goes too — seed-policies.sh no longer takes a release directory, because there is no longer a release copy to fall back on. A board that cannot reach the Hub on a first install therefore has no gait, and that is the accepted shape rather than an oversight: robotd holds its pose and reports degraded, so the update gate passes and nothing rolls back, and the next update fetches. It is the bargain setup-board.sh already makes for ONNX Runtime and setup-gstreamer.sh for the plugins — a board needs a network once. What the seeder must never do is *fail*, since a non-zero exit from the post-install hook would roll back a release over a network problem that had nothing to do with it; every error path exits zero and says so on stderr. The provenance table moves to policy-channel-design.md §10 rather than being deleted with the README. It records which upstream training run each role name points at — including the roller_crouch typo fix — and that mapping is not recoverable from the file names on the Hub, so this repository was about to be the only place it had ever been written down and then not be. The pin is still v1 and the tag does not exist yet, so boards keep whatever they have and the fetch starts working the moment it is created. Assisted-by: Claude:claude-opus-5[1m]
From a board: one slot was overridden, `policy reset` was run, and all seven reported "is back to this robot's own policy". Six of them had not moved. The command was doing the right thing and describing something else, which is worse than either — it reads as though a reset touches everything, which is exactly the impression to avoid about a command whose whole job is to be safe to type. It now reports the slots that changed and counts the rest: the daemon is asked what it is running before the request, and the same per-slot predicate that decides when the swap has landed decides which slots the request moves. One question, one answer — a reset that waited on one set of slots and reported a different one would be the same bug wearing a different hat. A slot carrying an error counts as changing whatever its path says. It has fallen back, so it reads as not-overridden and running the default, indistinguishable from a slot nobody touched; resetting it is what clears the error, and calling it unchanged would describe the one case somebody is most likely running the command for. The footer went with it. `(kept in …; robotctl policy reset undoes it)` was printed after a reset, where nothing had been kept and the suggested remedy was the command that had just run. It is a load's footer, it only prints for a load, and it now names the slot to undo rather than offering to reset everything. Assisted-by: Claude:claude-opus-5[1m]
Slice 2 moved the policy set out of the artifact and onto the Hub, and this section of the design claimed that bumping the pin was therefore "the whole of shipping a new gait: no daemon release". That was wrong, and wrong about the thing the channel exists for: the pin lives in Cargo.toml and in seed-policies.sh, both of which ship inside a daemon release, so bumping it needs exactly the release it was supposed to avoid. The pin is a floor. It decides what a freshly provisioned board installs, and `robotctl policy check` / `policy update` is how a board moves past it — which is the part that needed building, and now exists. policy.check what is installed against what the repo offers policy.install fetch a revision and make it live Both on updaterd, because they need a network stack: robotd has none by design and robotctl must not link one, being the tool that has to work when everything else is broken. `check` is a read and stays ungated and answerable during an update; `install` is mutating and takes the engine lock, because two things rewriting what the robot runs at once would be decided by whichever finished last. Three things worth their own lines. The repo comes from the set, not from configuration: each installed set carries a `.source` record naming where it came from, written by whatever installed it. Nothing configures the repo twice and there is nothing to drift, and a set some future tool installs answers the same question the same way. Newest is the repo's own newest reversed, not a semver sort. A policy repo is not obliged to use semver and ordering `bouncy-2` against `v10` would be a guess presented as a fact — so `check` prints every revision and lets a person choose, which is also what makes going *back* a first-class thing rather than an afterthought. And robot.reloadPolicies, because installing a set swaps `current` underneath unchanged paths: every slot still resolves to the same string, so loadPolicy is right about the paths and wrong about the bytes. It is never short-circuited — "already loaded" is the answer it exists to disbelieve. That last one nearly shipped a bad bug. The intent was a pair of Options, in which "reload" and "reset every slot" are the same value, so the reload at the end of every install would have silently discarded every `policy load` on the board. Three things to say needs three things to say them with; it is an enum now, and a test says a reload is not a reset. API_VERSION 17 -> 18. Refused over BLE, and over WebRTC too: a daemon update that goes wrong reverts itself, while a gait that goes wrong walks badly, which nothing detects and only somebody watching the robot can judge. Assisted-by: Claude:claude-opus-5[1m]
Two faults, one report from a board that had just updated and was walking fine.
The provenance record was added in the same change that started reading it, so
only a set installed *after* it existed had one. A board seeded before that
takes the fast path — the pinned set is already installed, no network needed —
which exits before writing anything, forever. It never gains a record, and
`policy check` reads a robot with nine working policies as having none.
The seeder now back-fills on that branch, once: the record is written when it is
missing and left alone when it is not, so this costs no network and no eMMC
churn on the update that repairs it or on any update after.
The second fault was the message. "Nothing installed" was reported through the
`unreachable` field, so the answer read
the Hub could not be reached — no policy set is installed …
which blames the network for something that is not about the network, on a board
whose Hub was perfectly reachable. It is not an error at all now; an absent repo
says it on its own, since there is no other way to get one. `robotctl` prints
what such a board should actually go and look at — the postinstall hook installs
the set, and `robotctl health` says whether a slot failed to load.
Both are the same shape of mistake: a state that had never happened yet when the
code was written, described by reusing a field that meant something else.
Assisted-by: Claude:claude-opus-5[1m]
`policy update` works, and that is what exposed this. A board moved forward by hand has `current -> releases/seed-v2`, which matches the `seed-*` pattern seed-policies.sh treated as its own to replace — so the next unrelated daemon update, pinned at v1, would have reinstalled v1 and reloaded it. Someone's chosen gait reverted as a silent side effect of a binary update, with nothing saying so. The rule that allowed it was written before `policy update` existed, when a daemon update genuinely was the only way a retrained gait reached a board. It is not any more, so the seeder now has two states rather than three: something is installed, or nothing is. Only the second one fetches. Everything after the first install belongs to `policy check` and `policy update`. The prune loop went with it — the seeder installs at most once per board now, so it had nothing left to prune — and moved to `policy::install`, which is where directories actually accumulate. Same rule as before: keep the live set and the one it replaced, touch nothing that is not ours. The predecessor is kept because rollback does not run hooks, so reverting the daemon does not revert its policies and pointing `current` back by hand is the recovery. Also, policy files were being fetched with `get_bytes`, which is for manifests and API replies and refuses anything over a megabyte with the words "implausibly large for metadata". Today's policies are 775 KB, so it worked, and the first retrain to produce a slightly larger network would have failed to install with a sentence about metadata. They go through `download_to` now, which is what the artifact path uses. Verified against the live repo: a board on v2 stays on v2 when the seeder runs pinned at v1. Assisted-by: Claude:claude-opus-5[1m]
`robotctl policy load walk RemiFabre/microduck-flamingo-cycle` fetches a policy into /var/lib/robot/policies/<org>/<name>/<rev>/ and runs it, and `robotctl policy search microduck` says what is out there. Both through updaterd, which is the process with a network stack. Looking at what people have actually published changed the design rather than confirming it. There are already several microduck policies on the Hub, and they share two conventions nobody wrote down here: one `policy.onnx` at the repo root, and a `manifest.json` carrying obs_len, action_len, model_api, robot.model, a name, a kind and a description. So the fetch reads the manifest first. A policy that says it is 51-D, or needs a newer daemon, or is for a different robot, is refused before 800 KB is downloaded and before the robot is asked to run it — the same verdict robotd would reach at load, arriving somewhere a person can act on it. That is also where `model_api` stops being the thing updater-design.md §5.5 specified and neither side implemented: robot.modelApi answers, and this is what asks. Three rules keep the manifest from becoming a trap. It is untrusted, so it can refuse and never bless — one that lies is caught by the shape gate, which is where the real check has always been. Absence is not evidence: a repo without one is accepted, because most of the Hub follows no convention of ours and refusing on silence would reject nearly all of it, including one of the three policies published today. And the numbers it is checked against now live in duck-ipc-proto, with a compile-time assertion in duck-control that the two agree, because a contract with whoever publishes a policy belongs where both sides of it can see it rather than in two copies. Origin is the org in the path, which is why the library mirrors the repo: a stranger's policy reports `community` with no lookup and no sidecar read. It is a label and not a boundary, and the comment says so — anyone who can edit robotd.toml can name a directory whatever they like, and anyone who can do that can run whatever they like anyway. A repo carrying two policies is refused by name rather than guessed at. None does today; picking wrong means running the wrong network on a real robot. Checked against all three published policies: two pass the manifest gate on their real fields, and the one with no manifest is accepted for the shape gate to judge. Assisted-by: Claude:claude-opus-5[1m]
…needs Trying to answer "how do I run Remi's flamingo policy" turned up a gap. Its README says: put it in the walk slot and set `stand = "none"`. The second half is not optional — the policy does its own two-foot stand, and `will_stand` hands the robot to the standing network whenever command magnitude is zero, which is exactly the state that policy is in while standing on two feet. Leave the standing net in and it takes over the moment the flag drops. `robotctl policy load` could not express that. The config has always had the `"none"` sentinel, so running the first published community policy meant editing the file the command exists to stop editing. `policy load <slot> none` now switches a slot off, using the same literal the config does — made public in robotd-params so the three places that must agree on it share one definition rather than three spellings. It names no file, so the absolute-path rule and the shape check both stand aside for it, and it needs a slot: without one it is indistinguishable from resetting everything, and guessing between those is not on. The distinction that needed care is that a disabled slot and a slot this robot simply does not have look identical from outside — both report no policy. Roller mode has no standing network and nobody asked for that. `overridden` is what tells them apart, and there is a test for each. Assisted-by: Claude:claude-opus-5[1m]
…estart
Reported from a board. It was accepted, and then `PolicyParams::resolved` did
`.expect("walk always has a default")` on the `None` the sentinel produces —
inside the control thread, which died. The daemon stayed up answering its
socket, so `policy list` still replied with the pre-panic snapshot and the robot
looked fine while it had stopped ticking. robotctl had already written
`walk = "none"` to the config, so a restart panicked the same way in the loop's
preamble: one line in a file, and the robot's control was gone until somebody
edited it back by hand.
Three changes, because one was not enough anywhere.
`resolved` no longer panics. `walk` is the slot every other one falls back to,
so the sentinel does not apply to it and it resolves to the mode's default
instead. This is the floor: whatever gets into a config file, the control thread
survives reading it.
`robot.loadPolicy` refuses `walk none` outright, with `policy reset walk` as the
way out. A request that would be quietly ignored is worse than one that is
answered, and now that the config layer falls back rather than obeying, ignored
is what it would be.
And a board that already has the line is repaired at startup: dropped, the robot
walks, and health reports degraded naming it — the same treatment every other
override this board cannot honour gets, for the same reason. The release is
fine, and rolling one back would fix nothing.
Every other slot can still be switched off, which is what running a community
policy that owns the whole command block needs. Only `walk` is special, and it
is special because it is the fallback.
Assisted-by: Claude:claude-opus-5[1m]
Six slots were deliberately off to run a community policy in the walk slot, and `policy list` rendered them `- (none)` — which is also how roller mode's absent standing network renders, and how a slot nobody has ever touched renders. The robot was walking with no standing net, at zero command, and nothing on screen said so. It read as odd behaviour rather than as configuration, and finding out which took an evening. `overridden` was in the wire data the whole time. `robot.policies` returns it and `slot_holds` uses it; the renderer just never printed it. So a switched-off slot now says `off / switched off`, every row config has an opinion about carries a `*`, and a footer counts them with `policy reset` as the way back. A robot running entirely its own policies gets no footer — on a stock robot it would be noise. The command whose job is "what is this robot running, and what did I change" could not answer the second half. Now it can, at a glance, which is the point of having it rather than reading the toml. Assisted-by: Claude:claude-opus-5[1m]
Coverage70.67% lines on this branch, against a floor of 70%. Per-file |
A holding-pen note, not a design. Adding a one-shot move touches seven places today, and a community policy that is the same shape as roulade — zero command, four seconds, selecting it is the trigger — still cannot be added without a release. Before changing that it is worth having the current picture somewhere other than in six files. The thing worth separating, and the reason for writing it out: there are three layers, and only the third is what a move list touches. padd's modes and its compiled-in button table; robotd's bring-up and limp-fall machines, which decide *whether* a policy drives; and the select cascade, which decides *which* one. The first two know nothing about which policy is running. It also settles a premise I had wrong: one-shots already run on top of walk and stand with nothing disabled. The eviction a community policy needs today is only because it goes into the walk slot — the fallback itself — rather than into the cascade. Assisted-by: Claude:claude-opus-5[1m]
pierre-rouanet
force-pushed
the
policy-hub-design
branch
from
September 1, 2026 11:18
602d293 to
3c1910c
Compare
Adding a one-shot touched seven places: the Skill enum, Net, PolicyPaths, a
Policy field and its has_*, a Slot with a config key and a registry entry, a
branch in the control loop, and padd's table. A community policy that is the
same shape as roulade — zero command, four seconds, selecting it is the trigger
— could not be added at all without cutting a release.
The reason it was seven places is that it did not need to be one. These two arms
were the same arm:
} else if let Some((left, _)) = self.kick {
let net = if left { Net::KickLeft } else { Net::KickRight };
(net, Command::default(), label)
} else if self.roulade.is_some() {
(Net::Roulade, Command::default(), "roulade")
Kicks and roulade differed in four numbers — duration, action scale, gain ratio,
and whether holding the button chains another — and in nothing else. So they are
four numbers now. `Net::Skill(index)`, one `ActiveSkill` where there were three
fields, one arm where there were two, and `[[policy.skill]]` carrying the rest.
`fffiloni/microduck-polite-bow-b1d864` is four lines of config.
Absent means the built-in three, and an entry merges by name, so a board updates
onto this with no config written and no migration run — and adding a skill does
not mean re-declaring the ones already there, where forgetting one would
silently remove it. `"none"` removes a built-in, the same word that switches off
a policy slot.
Per-skill overrides are raw parameter names in `[policy.skill.params]` —
`cmd_alpha`, not an invented `smoothing = "off"` — applied on entry and restored
on exit. A named set rather than any key: a skill that could widen a joint limit
or lengthen the deadman would be reaching past the layer that makes a stranger's
policy safe to try, which is the entire argument for allowing one.
`Skill` on the wire is a name. Nothing outside this workspace consumed the enum,
so the API_VERSION bump has no client to coordinate with, and `robot.subscribe`
now answers with the list rather than five fixed fields — a client learns what a
robot can do instead of assuming.
The registry gained `Kind::Table` rather than an exemption. A repeating table is
not one value with one cursor position, so `configure` lists it and points at
`robotctl policy`; it is *in* the registry so the completeness test keeps meaning
what it says, and the next repeating section cannot go unnoticed. Three stale
keys came out with it.
Left alone deliberately: walk and stand are the fallback pair chosen by
magnitude, sitstand is latched and driven internally by the shutdown sit and the
seated-boot rise, and ground_pick writes a scripted phase rather than a
constant. Generalising that last one means the descriptor grows a command
generator, and nothing needs it yet.
Assisted-by: Claude:claude-opus-5[1m]
I read `kind: perpetual` in flamingo's manifest as "not a one-shot" and said so. That was reading a field instead of the behaviour: pressing a button, lifting a foot for five seconds and putting it down is a one-shot interaction whatever the network's own lifetime is. What perpetual actually means, operationally, is *who supplies the ending*. polite-bow is episodic: four seconds later it is standing again, so the window expires and walk inherits an upright robot. Flamingo holds until told otherwise — so a window that just expired would hand walk a robot balanced on one leg. So a skill can declare both halves. `command` is the twist while it runs, zeros for every one-shot published so far and `[flag, side, 0]` for flamingo. `unwind` and `unwind_s` are what it drives on the way back, which is the daemon supplying the ending the policy does not have — the same two-phase shape the sit toggle already uses on its way up, where the network keeps driving for a moment after the flag drops because dropping it does not instantly make the robot stand. `unwind_s` of zero is the default and skips the phase entirely, so nothing about the built-in three changes and no config file has to mention either field. Assisted-by: Claude:claude-opus-5[1m]
…mmand
The config shape worked and nothing wrote it — a community skill meant
hand-editing the TOML, which tests the daemon and not the thing anybody wanted.
robotctl policy add polite-bow fffiloni/microduck-polite-bow-b1d864
fetches it, reads how long it runs from the manifest, and writes the entry. By
name, so running it again because a number was wrong retunes the skill rather
than giving the robot two of it. `policy remove` takes one out and says whether
there was one to take.
A policy that ends itself needs nothing else. One that does not — `perpetual`,
no `duration_s` — is refused with what to do about it, because guessing a length
for a network that holds until told otherwise means the gait takes over
mid-pose. `--hold` and `--unwind` are how a person says it, and `--command` and
`--unwind-command` carry a twist for a policy with its own encoding.
Only what differs from a plain zero-command one-shot is written, which is what
the shipped config's comments promise about every other key: the file stays a
list of decisions. Comments and everything else survive, because this is the
same `toml_edit` document and the same validation through robotd's own loader
that `configure` uses — what it writes, the daemon starts on, and a test asserts
that by loading the file back through `Params::load`.
The one wart worth naming: the write is not queued through `configure`'s pending
edits, because a repeating table has no key to queue. It shares the validation
and the atomic rename and nothing else.
Assisted-by: Claude:claude-opus-5[1m]
Asking whether polite-bow needs any params was the right question, and the answer turned up that half the override set does nothing. `cmd_alpha` cannot apply to a skill. Smoothing is done to the *client's* command on its way in, and a skill never reads that — the loop builds a fresh command block from `command` or `unwind` and feeds it straight to the network. So a skill's twist is unsmoothed by construction, which is exactly what a policy reading a flag rather than a velocity needs. It is also why driving flamingo through `robot.move` into the walk slot needed `cmd_alpha = 1.0` set globally and remembered afterwards, and why as a skill it needs nothing. `limp_fall_tilt_z` is worse: I wrote it, the CLI wrote it into config, and nothing ever read it. Wiring it up turned out to be the wrong fix, because the limp-fall predictor is only consulted while the controller is not `busy()` and any active skill makes it busy. A running skill already has the fall reflex off, so a field to raise the gate could never have mattered. That last part is worth more than the field was. The reflex being off for the whole of a skill was uncontroversial when every one-shot was under a second — a kick is over before a fall develops — and a skill that can be configured to hold for ten seconds is a robot with no fall reflex for ten seconds. Written down where it is true and in the ideas note, because it wants deciding rather than inheriting. So a plain one-shot needs no params at all, which is what polite-bow is: a name, a path and a duration. Assisted-by: Claude:claude-opus-5[1m]
…obot up `policy add` wrote the config and told you to restart robotd, which is a poor answer twice over: trying a gait is meant to be cheap, and restarting the daemon drops motor control — a standing robot falls over because somebody wanted to try a bow. `robot.reloadPolicies` now re-reads `[policy]` from disk instead of only re-resolving what was loaded at startup, which was the reason a new skill needed a restart at all: the loop held a clone of the params read once, so a skill written to the file after that was invisible to it. `policy add` and `policy remove` send the reload themselves and say whether the robot took it. Nothing goes limp. The reload takes the same path a mode switch has always taken: ramp to the home pose with torque on, rebuild there, resume. That is why the swap is safe at all, and it is the whole difference from a restart. Three carve-outs, each deliberate: The mode is carried over rather than adopted from the file. `robot.setMode` does not write config on purpose, so taking the file's mode here would put a duck on wheels back on its legs as a side effect of adding a skill. Only `[policy]`. The daemons read their config once at startup and that stays true of `[safety]` and `[control]` — re-reading those under a running loop is a much larger promise than this needs to make. A file that will not parse leaves the robot exactly as it is, with the error in the journal. It is the caller's file and their mistake to fix, and dropping a working policy set over a syntax error is the worse of the two outcomes. Also: `robot.do` now refuses when the policy is not driving, instead of accepting and letting the loop silently drop it with a journal line. Same "accepted, then nothing" that made a policy load look broken this morning. The message names the pad and nothing else, because nothing else can start the policy — `robotctl robot` has `init` and `relax` and no `enable`, which I nearly wrote into the error before checking. Assisted-by: Claude:claude-opus-5[1m]
…dy say Adding flamingo as a skill needed two numbers from the command line that the policy itself knows: what command means "stop", and how long it takes to get there. Both are in its manifest already — `command.idle` is [0,0,0] and `eval.transition_time_s` is 1.5 — so the gap was that nothing read them. `policy add` now takes the unwind twist from `command.idle` and the unwind duration from a top-level `unwind_s`, with the command-line flags still winning: a person watching the robot is a better judge of how long it needs than a number measured in simulation. `unwind_s` is the one field that does not exist yet. Flamingo's manifest carries the number under `eval.transition_time_s`, which is the block recording how it was evaluated rather than the contract the daemon acts on — reading a contract out of an evaluation note is the kind of thing that is fine until somebody reasonably changes what `eval` means. Promoting it is the whole ask. Written down in the ideas note as a table of what the daemon acts on and what happens without each field, so the publishing side has something exact rather than my description of it. Also written down there: `roulade` is 1.0s and the kicks are 0.5s because `builtin_skills()` says so, which is the same coupling this exercise removed for *which file* a slot runs, still present for *how long*. Assisted-by: Claude:claude-opus-5[1m]
…elease Two lists stood between the official set and a new policy, and both were in this repository: `POLICY_FILES` in the seeder, or it is never downloaded, and `builtin_skills()` in robotd-params, or it is not a skill. Either way a daemon release. That is the coupling this whole exercise removed for a stranger's policy, still in place for our own. The set now carries a `manifest.json` describing each policy — what file, what kind, and for a one-shot how long it runs. The seeder takes its download list from it and robotd takes its skills from it, both falling back to the nine and the three they know for a revision tagged before the manifest existed. Three kinds, and only `episodic` becomes a skill. A gait is not something to ask for by name, and a perpetual one-shot has no length of its own — how long to hold a foot up is a person's choice, so it takes a config entry rather than appearing. `scripted` is the third and it earns its place: the ground pick writes a phase over time rather than a constant, and marking it episodic would drive it with an all-zero command. The per-policy fields are the ones a single-policy repo already uses, plus `file`. That is what makes the ask to a community publisher "add these fields" rather than "adopt our format", and it means one reader understands both. A first pass checked the manifest into deploy/ with a test pinning it against the reader. Both are gone, because the test pinned the wrong artifact: the file a robot downloads is on the Hub, so a copy here drifting means the test passes green while boards get something else. Worse than no test. The format is documented in policy-channel-design.md §9.3 instead, which owns the channel. What replaces it is a guard where the file is actually read: a set entry may not answer to `ground_pick` or `sit_toggle`, which have their own arm of the cascade and would otherwise get a second network fed a command it never trained on. Writing the test showed the guard is on the name and not the file, so a set that mislabels a scripted policy without renaming it still produces a junk skill under its file stem. Left through on purpose and said so in the code: it shadows nothing, it is visible in `robotctl policy list`, and closing it would take a hardcoded list of our own filenames — the coupling this manifest exists to remove. Assisted-by: Claude:claude-opus-5[1m]
The Hub returns refs in no useful order and puts no dates on them. Our own set comes back as v3, v1, v2. The rule here was "the list, reversed", which makes v2 the newest — so a board reported itself up to date on v2 while printing v3 two lines below, and `policy update` installed nothing. The reasoning behind that rule was sound and the conclusion was not. A policy repo is genuinely not obliged to use semver, so ordering arbitrary tag names would be a guess presented as a fact — but the Hub's own order is not chronological either, so taking it was the same guess with less thought behind it. Ordering is now by the numbers in the tag, compared as numbers, so v10 sorts above v9 where lexically it does not. A tag that is not a version keeps its place at the end and never counts as newest: `policy update` with no argument cannot land on somebody's `experimental` because it happened to sort first, and naming it explicitly still works. That is a narrower claim than an ordering over arbitrary names, which is the guess still worth not making. The first test uses the exact list the Hub actually returned rather than a tidy ascending one, because a tidy one would have passed under the old rule too. Assisted-by: Claude:claude-opus-5[1m]
…answer A flamingo trial leaves `cmd_alpha` at pass-through, a slot pointed at somebody else's file, another switched off and a fall gate widened — and this morning the only way to see that was `robotctl configure`, a TUI, over ssh, on a robot already behaving oddly. The comparison existed the whole time in `Row::differs`; it was simply unreachable without taking over the terminal. `robotctl configure --list` prints the divergences and nothing else, one key per line with its default beside it, and `--json` for a support bundle. No root and no terminal, because this is the first question to ask and it should be the cheapest. A robot nobody has touched prints one line saying so. That is the honest answer: the shipped file sets four keys and comments out the rest, so "nothing" is the useful output, and a hundred lines of defaults would bury the two that matter on a robot where something has changed. A key written out with its own default is not a divergence either — the shipped file does that in places. Two of the three tests I wrote for this already existed under other names (`writing_the_default_out_is_not_a_divergence`, and the one asserting the shipped example diverges from nothing). Dropped both rather than leave the same property asserted twice; the one that is new checks that a config carrying the exact leftovers of a flamingo trial names all four. Assisted-by: Claude:claude-opus-5[1m]
…tton Skills became config three commits ago and the pad did not follow: a robot could learn `polite-bow` and had no way to put it on a button. The mapping was two hardcoded places per skill — a gilrs `Button` match arm setting a flag, and an array entry pairing that flag with a name — so binding anything meant a release. `[pad]` in robotd.toml now carries the five one-shot buttons, `robotctl pad bindings` shows them and `robotctl pad bind x polite-bow` changes one. Defaults are the prototype's mapping, so a robot with no `[pad]` behaves exactly as it always has, and binding back to the default removes the key rather than pinning it — the file stays a list of decisions, which is what makes `configure --list` worth reading. Only those five. Start toggles the policy, Y and B change what the sticks mean, held Select powers the robot off, held D-pad up switches drive mode: none is a `robot.do`, and putting the button that stops a robot behind a config key is the one binding worth not being able to lose. `padd` gained `robotd-params` and reads the mapping at startup. It still knows nothing about what a skill *is* — it reads which button went down, looks up the name beside it, and sends that name. Checking belongs where the answer is: `robotctl pad bind` asks the robot what it has and refuses a typo with the real list, and an unreachable robot is not a refusal, only an unchecked name. The X-held chain follows whatever X is bound to rather than the word "roulade"; a skill that does not chain refuses the resend, which costs one notification. The fix worth naming is one line: `unit_for` would have offered a `robotd` restart for a `[pad]` change, which drops motor control and puts a standing robot on the floor to apply a setting robotd never reads. It offers `padd` now, with a test. Two existing tests caught what I would have missed — the section ordering and the registry completeness both failed until `[pad]` was properly declared rather than merely added. Assisted-by: Claude:claude-opus-5[1m]
… it back Two gaps in the bindings, both found by someone trying to use them. `padd` read the mapping once at startup, so changing a button meant restarting it. Mild next to restarting robotd — padd holds no motor control — but it is the same friction, and this one is cheap to remove: it now checks the config's mtime once a second and re-reads when it changes. A stat at 50 Hz to catch a file edited a few times a week is work for nothing, and a second is faster than typing the next command. Safer than the robotd reload, too: there is no controller to rebuild, only a table swapped between two ticks. A file caught half-written falls back to the defaults with a line in the journal and the next read gets the finished one, which matters more now that it is read while running. `pad reset [button]` is the undo, the same shape as `policy reset` — no argument means all five. It only writes the buttons that actually differ, so on an untouched robot it says "already the default" rather than reporting five changes it did not make, and afterwards `configure --list` reads clean. That last part is the property worth testing: an undo that leaves the file looking modified is not an undo. And the naming that prompted the question: `lb`/`rb` are the *bumpers*. The analog triggers are the mouth and the quack and are not bindable. gilrs calls the bumpers `LeftTrigger`/`RightTrigger` and the analog ones `LeftTrigger2`, and I had chosen `lb`/`rb` for the config precisely to avoid inheriting that — but only said so in a comment, where somebody reading `--help` and expecting the trigger would never see it. It is in the help text and the cheat sheet now. Assisted-by: Claude:claude-opus-5[1m]
The commands landed one at a time and the documentation did too, so the cheat sheet had five policy subsections filed under "Configuring the robot" — the section about the TUI editor — in the order they happened to be built. Two of them were wrong by the time the next one landed: `policy add` was documented nowhere at all, and the advice for running a community one-shot still said to put it in the walk slot, switch the standing network off and drive it from a script, which was true this morning and has not been since it could be a skill. "Policies and skills" is now its own section, ordered as somebody actually arrives at it: what is running, a newer official set, your own file, somebody else's, adding a skill, putting it on a button, and putting it all back. The gamepad section points at it rather than repeating a button list that is now config. The ideas note graduates into `policy-channel-design.md` §10. `docs/ideas/` is for thinking written down before it has a design; this has one now, and the design doc is where a page that owns a mechanism belongs — §10.1 on who supplies a policy's ending, §10.2 on what stays in the daemon and why, §10.3 on the button. Four decisions added to the table and two items to what is open: a running skill has no fall reflex, and `configure` lists the skill table without editing it. Assisted-by: Claude:claude-opus-5[1m]
The docs described the binding commands without showing either of them running, which for a listing is most of what a reader wants: five buttons and what each one does, before and after a bind, and the one line it writes. Adding the real output turned up that the listing's own footer still said "padd reads it at startup". That was true when it was written and stopped being true two commits later, when padd started re-reading within a second — a line that tells somebody to restart a daemon they do not need to restart. Assisted-by: Claude:claude-opus-5[1m]
The format was described and never shown, which for a file somebody has to write is the wrong way round. §9.4 has it in full, read against what a robot does with each part: the nine `file` entries are the download list, the three `episodic` ones become skills — reproducing today's built-in three exactly, which is the check that it is right rather than merely plausible — and `ball_kick_left.onnx` carries a `name` because its role differs from its training run while `roulade.onnx` does not. Shown in the doc rather than checked in as a file, deliberately. A copy here would be a second source of truth for something that versions on the Hub, and a test over the copy would pass while a board downloaded something else. That mistake was made and reverted earlier in this branch; the doc says why. With it, the four steps for adding a policy to the set — upload, one manifest entry, tag, `policy update` — and what `kind` decides, since that is the field that determines whether a new policy becomes a skill, needs a slot, or is something the daemon drives itself. The cheat sheet carries the steps and points at the design doc for the fields. Also recorded as open: none of this is reachable remotely. Every policy method is refused over BLE and all but the `robot.policies` read over WebRTC, written as deferrals with what would lift each. The pad bindings are further out than the rest — `robotctl pad bind` edits the config file directly, so there is no wire surface at all to route, and exposing them needs new methods plus a decision about which daemon owns them. Assisted-by: Claude:claude-opus-5[1m]
Every refusal on the policy methods said the same thing: the safety layer is unchanged whoever asked, and what was missing was a client that watches the robot. There is one now, so `robot.do`, `robot.policies`, `robot.loadPolicy` and `robot.reloadPolicies` are served over both BLE and WebRTC. Reading those arms turned up two things. `robot.do` was grouped with `robot.move` and the rest under BLE's transport argument — a 20-byte notification budget and a link that does not exist for the first ~73s of a boot. That argument is about a stream: fifty small updates a second. A skill is one request, and it needs no control link at all, because the deadman zeroes the twist by itself and a robot with nothing driving it stands still and bows. It had been lumped in with teleop and is not teleop. There is now a test asserting teleop stays refused, so widening that pattern later cannot quietly take it along. And the authentication runs opposite to the intuition. BLE is the authenticated transport — PIN-bonded, `encrypt_authenticated_write` — and ten metres of radio range means whoever tapped the button is in the room with the robot by construction, which is the watching condition most of these refusals turn on. WebRTC has no authorisation at all, so any LAN peer inherits what is opened there. `robot.loadPolicy` is also the only call here whose effect persists: it writes `robotd.toml` and survives a reboot. So the call with the longest-lived effect is the one whose exposure differs most between the two transports. Opened on both, and written down as a reason to sharpen §4 of remote-webrtc.md rather than to withhold the call from the transport that can show somebody the result. `robot.policies` gains the skill list, which is what makes the rest usable: a client cannot offer a bow without being told the robot has one, and which skills exist is config now. The names were already in `robot.subscribe`'s acknowledgement, but that is a 50 Hz stream answering a question asked once, and BLE deliberately does not route it. API_VERSION 18 -> 19, additive — an older robotd omits the field and it deserialises to empty. Installing from the Hub stays local-only, now for a narrower reason than before: not the blast radius, which `robot.loadPolicy` shares, but that `policy.install` reaches the network on the robot's behalf and writes the eMMC, where loading points at a file already on it. Assisted-by: Claude:claude-opus-5[1m]
The routing opened four methods over BLE and `duckctl` could only reach them through `call` with hand-written JSON, which is the escape hatch for what is not wrapped rather than a way to use a feature. `duckctl policy list`, `policy load`, `policy reset`, `policy reload` and `duckctl do <skill>` — the same words in the same order as `robotctl`, which is what the rest of this tool does so that what somebody learns on the robot transfers to the radio and back. Two things the wrapping makes true that the JSON did not. A load homes the robot and rebuilds the controller before it answers, so it takes the slow-call budget; on the ordinary reply timeout it would look like a robot that had stopped talking mid-swap. And `policy reset` is `loadPolicy` with the path *omitted* rather than null — the shape the daemon's `Option<String>` is written against — which is pinned in a test because finding that out on a robot is the expensive way. `policy load` takes a path and never `org/repo`, because reaching the Hub is refused on this transport. Said in the help and in the docs rather than left to be discovered: the two commands look identical on `robotctl`, where one string means both. Assisted-by: Claude:claude-opus-5[1m]
`<option value="kick_left">` and four more, written into the page. That was right when a robot had exactly five skills and every robot had the same five; it stopped being right when skills became config. The menu was wrong in both directions — missing the bow somebody added, and offering a kick somebody had taken away, which fails on press with no clue that the page was the one out of date. It asks `robot.policies` when the channel opens and builds the menu from the answer. That read is routed over WebRTC and now carries the skill list, which is the reason it carries it. An empty list is treated as "this robot did not say" rather than "this robot has no skills": it means an older daemon, so the page offers the five it certainly has and logs which case it is in. Those are a robot to update and a robot to leave alone, and a menu that went blank would not tell them apart. Assisted-by: Claude:claude-opus-5[1m]
Working out where the pad bindings belong meant reading how `robot.loadPolicy` actually saves a choice, and it does not. `robotd` mutates its own in-memory params and reloads; writing `robotd.toml` is `robotctl`'s half of `policy load`. The design doc has always been right about this — §3 describes the persistence model as a property of the *command* — but two route comments and the section I added yesterday attributed it to the method, and built an argument on top of it: that `loadPolicy` was the riskiest call to open remotely because its effect outlived everything else. The opposite is true. It is the least durable thing opened on either transport. Corrected in all three places, and the real finding recorded as open: a gait chosen from a phone is gone at the next restart, which is the ephemeral "try it until reboot" mode §3 considered and rejected for the local path. Arrived at by accident rather than by decision, and `robotctl policy load` and `duckctl policy load` now differ in durability while sharing their words, which is a trap whichever way the question is settled. It also settles where the pad bindings can go. `padd` re-reads `[pad]` every second, so a `pad.bind` that only changed something in memory would be reverted within a second — there is no live-only option there. Whichever daemon serves it needs the lossless writer that today only `robotctl` has. Assisted-by: Claude:claude-opus-5[1m]
It began as part of `robotctl configure`, when that was the only thing writing `robotd.toml`. Two more callers have appeared since — `robotctl policy` and `robotctl pad` write keys of their own — and a daemon serving `pad.bind` over the radio needs the same writer, because `padd` re-reads `[pad]` every second and a binding that only changed something in memory would be reverted before anybody let go of the phone. So it is `robotd_params::edit` now: `Row`, `Edit`, `Model`, and the pad helpers. The guarantee worth keeping is that nothing writes a file `robotd` would refuse to start on, and that guarantee is only as good as its least careful writer — so the writer belongs beside the schema it validates against rather than in one of its callers. `toml_edit` is pure Rust, so this crate's rule that nothing in it grows a C toolchain or a network stack still holds. The twenty tests that exercise the model moved with it, which was the point of doing this properly rather than re-exporting and leaving them: `robotd-params` is the crate on the recovery path, and shipping a writer there with its tests in another crate would be the wrong way round. `from_text` is public now so a caller can build a model over text it has already read — mostly used for testing, which is a good enough reason on its own: a config written as a string says what a case is about better than a temporary file does. What stayed in `robotctl` is what is genuinely an operator tool's: which systemd unit a change needs restarted, restarting it, the divergence listing and the full-screen editor. `configure.rs` goes from 1680 lines to 794. One test came back after going the wrong way. `a_save_remembers_what_it_wrote_so _the_right_daemon_restarts` reads as a model test and is a restart-mapping test; it needed `units_to_restart`, which is exactly the line between the two modules. Assisted-by: Claude:claude-opus-5[1m]
The last thing on this branch with no wire surface at all. `robotctl pad bind` edits the config file directly, so unlike every other command there was nothing for a phone to call and routing could not help — the methods had to exist first. `pad.bindings` and `pad.bind`, served by `robotd` rather than `configd`, which owns the rest of that namespace. Pairing a gamepad is about the radio; a binding is about what a button does to the robot, and answering it needs the list of skills this robot has so a name can be refused instead of becoming a button that does nothing. Routing is per method throughout this table — `policy.*` goes to `updaterd` while `robot.loadPolicy` goes to `robotd` for the same concept — so the split costs nothing mechanically, and it is only worth a comment because the name suggests otherwise. `pad.bind` writes the config file, and is the first call on either transport that does. It has to: `padd` re-reads `[pad]` every second, so a binding held in memory would be reverted before the caller let go of the phone. Which makes this, not `robot.loadPolicy`, the durable remote change — the opposite of what I assumed two commits ago, and worth remembering when remote-webrtc.md §4 is revisited. A test caught a real bug in the validation. `policies.skills` is not the list of names `robot.do` answers to: `ground_pick` and `sit_toggle` have their own arm of the cascade rather than being config entries, so they are absent from it while being perfectly good asks — and validating against `skills` alone rejected two of the five buttons the pad ships bound to. That list existed inline in `robot.do`'s own refusal message; it is `do_names` now, and all three callers share it. `duckctl pad bindings` / `bind` / `reset` alongside. The listing reports `overridden` so a client need not know the defaults, and `error` for a button bound to a skill the robot no longer has — arrived at by removing a skill rather than by mistyping, and otherwise discovered by pressing the button. API_VERSION 19 -> 20. Assisted-by: Claude:claude-opus-5[1m]
`policy.check`, `policy.search`, `policy.install` and `policy.fetch` are served over both transports. Browsing for a gait, seeing whether the official set has moved, installing one, pulling a stranger's onto the board — all of it from something that is not a terminal on the robot. The reads were refused on the grounds that answering "yes, there is a newer gait" for a client that could not then install one is an odd thing to offer. That was fair while it was true, and it stopped being an argument the moment the other half was routed. The mutations were refused for who is *watching*, and BLE answers that better than anything else in that file: ten metres of radio range means whoever tapped it is looking at the robot, and the bond is PIN-checked. What makes a stranger's policy survivable is the same whoever asked — the manifest gate before the download, the shape gate at load, the clamps, the fall reflex. The uid gate I flagged as an obstacle last night is not one. `policy.install` is `is_mutating` and `updaterd` authorises that against the peer's credentials, which are `btd`'s rather than the phone's — exactly as they already are for `update.apply`, which has been routed since the update path was driven from a phone. The transport is the gate there, not the credential. Both are named one by one in `only_these_mutating_calls_are_reachable_over_ble`, which is the list that makes routing a mutating method have to say why in a commit. `duckctl policy search / check / update / fetch` alongside, with the budgets their work needs: the two reads take the slow-call timeout because a mirror can have a bad day, and the two downloads take the update timeout for the reason `update apply` does. `fetch` rather than `add`, deliberately. `robotctl policy add` fetches *and* writes a skill entry so `robot do` can run it by name; this is the download half only, because `[[policy.skill]]` is a repeating table with no wire method. Two commands doing different things under one word is the trap I spent yesterday fixing in the docs, and it was not worth introducing a fresh one for the sake of a familiar name. Assisted-by: Claude:claude-opus-5[1m]
The last thing in the policy path only a terminal on the robot could reach. `[[policy.skill]]` is a repeating table, so `robotctl policy add` wrote it directly — there was no method to route, and a remote client could fetch a stranger's policy and fill a slot with it but never make it answer to `robot.do` by name. `robot.skills`, `robot.setSkill`, `robot.removeSkill`, on both transports. `robotd` writes the file and reloads itself, so one call is the whole operation: the alternative was a client writing and then remembering `robot.reloadPolicies`, and one that forgot would leave a robot whose config and behaviour disagree until the next restart — the state `robotctl policy load` exists to reconcile. `SkillParams` is the same shape read and written, and an existing entry supplies whatever the call leaves out, so changing a skill's command is one field rather than a full record. Without that, editing the command would silently reset the duration, which is the kind of thing found much later and blamed on the policy. Two validations worth having and one deliberately absent. A path that is not there is refused now rather than reported degraded at every restart over a typo, and `ground_pick`/`sit_toggle` cannot become table entries — an entry answering to either would shadow it with a network fed an all-zero command it never trained on. What is *not* checked here is the file's contents: the reload opens it and refuses the shape, reporting that slot degraded rather than taking the robot down, and duplicating the gate would only make it possible for the two to disagree. `robot.skills` reports `built_in` apart from the table, because those two are not entries and a client reading only the table would conclude they do not exist — the same trap `do_names` closed on the robot side two commits ago, arriving from the other direction. `duckctl policy skills / skill / unskill` alongside, with `--command` parsed here so a typo is this tool naming the shape rather than a PARSE_ERROR from the robot with nothing in it to act on. API_VERSION 20 -> 21. Assisted-by: Claude:claude-opus-5[1m]
Three field reports against the policy channel, from the same afternoon. `policy load walk <file>`, walk, sit, `policy reset walk` — and the robot ramped to home and stood up. Every policy change homed the robot and built a fresh Controller, which starts `Sit::Up`. The reset was right about the config and wrong about the robot: `sitstand` had it, and the reset did not touch `sitstand`. Now the change is compared against the network that stepped last tick, by resolved path. Only a change to that network goes home first; anything else is swapped in wherever the robot is, carrying the seat, skill and filter state across (`Controller::carry_over`). The networks load on a worker thread, because a swap can now land under a walking robot and a one-second stall of the command stream is a fall. A reload still homes: same paths, maybe different bytes. Select held: the robot sat, then either stood back up before the board went dark or went dark with its legs locked. The sit-complete tick cut torque and set `Limp` — with `snapshot.enabled` already read as true at the top of the tick, so the limp-and-enabled block further down turned torque straight back on and started the home ramp. That block and `robot.init` now stay quiet once the poweroff has been asked for. The cut itself is retried, and `DynamixelIo::set_torque` writes every servo instead of stopping at the first dropped ack, which on the way to a poweroff left the rest stiff with nothing running to tell them otherwise. And `policy load roulade <file>` reported the file and never ran it: the three skill slots resolved into report fields only, while the controller loads the skill list, which never read them. The slots now feed the same-named skill (the slot wins, keeps the entry's timing, `"none"` removes it) and the report is derived from the list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The first published manifest mislabelled three policies: the ground pick and the roller crouch as "scripted", the sitstand as "perpetual". Under the daemon's reading a mislabel the other way — "episodic" on the pick — would have loaded it as a zero-command skill, the failure duck_control::obs calls the hardest to see. kind and command.encoding are now two axes. An episodic entry on a constant command is a skill, as before; on a "phase" command it is the ground pick of its mode and its period_s / end_phase / action_scale are that mode's defaults; a "scripted" posture_flag entry is the sitstand, with unwind_s the rise and ramp_s the seat's settle (the shutdown sit waits twice that). "mode" tags roller entries. Phase and posture-flag entries are never loaded as skills, whatever they are named. This also corrects a number the daemon carried as a literal: the roller crouch is trained on a 5 s cycle (CROUCH_PERIOD), not the 3 s the roller preset inherited from the prototype. Without a manifest, or with one that predates these fields, every number resolves to the prototype's. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`robotctl policy update` ends in a reload, and a reload disturbed whatever was driving: the sitstand network had the robot, so the loop ramped to home before swapping — the stand-up — and the fresh controller started Sit::Up. The home-first ramp exists to stop a network being swapped under a moving gait. A seated robot is parked: a static pose on a constant flag, which busy() already says is not travelling. Driving::Seated is now its own state, no change disturbs it, and the swap happens in place with carry_over taking the seat across. The rise stays in motion and still goes home first. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A single-policy repo (the flamingo) and the official set spoke overlapping dialects: the set's "scripted" meant "the daemon writes the command", the community's "kind" carried no such thing, and neither said whether a held button chains. docs/policy-manifest.md is now the contract for both shapes: kind says who ends a policy, command.encoding says what the daemon feeds it, and a set entry is the same fields plus `file`. The updater reads the new fields off a single-policy repo and hands the encoding and chain over the wire. `policy add` honours chain and refuses a phase or posture-flag policy with the command that does load it — a skill feeds a constant, and a phase network on a constant is a robot moving plausibly and wrongly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A skill added a minute earlier appeared nowhere in the command somebody would look in, which reads as the add having failed. The listing answered half the question — a slot is what runs by default, a skill is what runs when asked — and only the first half had a table. It asks `robot.skills` as well now and prints both. A robot too old to know the method still gets its slots printed: refusing the whole listing over a missing half would be the worse of the two outcomes. Two things fell out of putting the tables next to each other. Durations read `0.5 s` rather than `0.500 s`, because three decimals suggests a precision nobody tuned to. And the directory comes off both — the ORIGIN column already says which one it was, seven identical prefixes are seven lines of noise around the part that differs, and for a community policy `fffiloni/microduck-polite-bow-b1d864/main/policy.onnx` is the whole of what a reader wants. A path somewhere else entirely is left alone, because then the directory is the interesting part. `ground_pick` and `sit_toggle` are listed with no length and "driven by the robot itself", because they answer to `robot do` like the rest while not being entries anybody can change — the third time that distinction has needed saying somewhere, after `do_names` and `SkillsResult::built_in`. Assisted-by: Claude:claude-opus-5[1m]
Schema 2 makes `kind` and `command.encoding` two axes, and the user-facing page had not caught up: it said `scripted` was "a policy the daemon drives itself, like the ground pick", which is now two claims in one sentence and both wrong. The pick is `episodic` on a `phase` command; `scripted` is the sit↔stand's posture flag. Rewritten as what somebody publishing a policy needs to decide rather than as a taxonomy: an episodic entry on the all-zero command it was trained against becomes a skill and needs nothing else, a perpetual one is a gait and needs a slot, and a policy the daemon has to drive says so under `command.encoding` and contributes timing rather than a new skill. The full field list stays in the design doc, which is where it belongs and where it is already right. The example entry was and is correct — three fields for a plain one-shot — but it now says which schema it is an example of. Assisted-by: Claude:claude-opus-5[1m]
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`docs/policy-manifest.md` arrived from the schema-2 work and says what it is: "§9 has the reasoning; this file is the contract." §9.4 was written before it existed and was the contract — the whole manifest inlined, every field, the publishing steps — so the two now describe one mechanism from two places, which `docs/README.md` names as the failure that put six documents in agreement about something none of them was right about. §9.4 is gone. §9.3 keeps the reasoning that lives nowhere else: why the manifest is on the Hub and not in this repository, and the two hardcoded lists whose removal is the point of it existing. Its two duplicated tables — the three kinds and the encodings — go with the section; what is left is the one sentence that explains why the mechanism is there at all. The inlined JSON deserves its own note. I argued in §9.3 against a checked-in copy of the manifest, because a test over a copy passes while a board downloads something else — then pasted the copy into §9.4 two sections later, where it can go stale in exactly the same way and did: it was schema 1. The new page links the live file on the Hub instead, which is the version of that argument I should have followed the first time. The publishing steps stay in the cheat sheet, which is where steps belong, now pointing at the contract rather than at a design section. And the new page is in `docs/README.md` — it was reachable only from prose, which for the page a publisher needs first is the wrong front door. Assisted-by: Claude:claude-opus-5[1m]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Policies stop being something a daemon release carries. They live on the Hugging
Face Hub, update on their own version line, and can be tried, added as a skill,
put on a button and undone on a running robot — without editing a file, without
a release, and without restarting anything.
This is M8. Proven end to end on a Radxa Zero 3W throughout.
What a person can do now
The five pieces
Loading is a config edit plus a live reload.
[policy] walkwas already anOption<PathBuf>where unset means "this mode's default", soloadwrites that key andresetremoves it — persistence and undo are the mechanism that already existed. The swaphappens at the home pose with torque on throughout, on the path
robot.setModehas usedsince v15.
The official set left the artifact.
robotdreads/opt/robot/policies/current, whichscripts/seed-policies.shfills by downloading the pinned set frompollen-robotics/microduck-policies.policies/and its three--includelists are gone:6.8 MB out of every daemon release, and CI builds from ~6 min to ~3.
The set describes itself. A
manifest.jsonon the Hub says what it contains and whateach policy is, so the seeder's download list and the robot's skills both come from it. A
tenth policy in the set is a tag, not a release.
{ "schema_version": 1, "model_api": 1, "obs_len": 61, "action_len": 14, "robot": { "model": "microduck", "hw_rev": 1, "servos": "xl330", "control_hz": 50 }, "policies": [ { "file": "alpha_walking.onnx", "kind": "perpetual" }, { "file": "alpha_stand.onnx", "kind": "perpetual" }, { "file": "alpha_sitstand.onnx", "kind": "perpetual" }, { "file": "roller.onnx", "kind": "perpetual" }, { "file": "alpha_ground_pick.onnx", "kind": "scripted" }, { "file": "roller_crouch.onnx", "kind": "scripted" }, { "file": "roulade.onnx", "kind": "episodic", "duration_s": 1.0, "chain": true }, { "file": "ball_kick_left.onnx", "name": "kick_left", "kind": "episodic", "duration_s": 0.5 }, { "file": "ball_kick_right.onnx", "name": "kick_right", "kind": "episodic", "duration_s": 0.5 } ] }The nine
fileentries are the download list. The threeepisodicones become skills, whichreproduces today's built-in three exactly — the check that it is right rather than merely
plausible.
scriptedkeeps the ground pick out of the skill list: it writes a phase over time,and calling it
episodicwould feed a scripted policy an all-zero command.So adding a policy for every robot is: upload the
.onnx, add one entry, tag it, androbotctl policy update. Not checked into this repo on purpose — a copy would be a secondsource of truth for something that versions on the Hub, and a test over the copy would pass
while a board downloaded something else.
One-shot skills are config. Kicks and roulade were the same arm of the control cascade
with four different numbers; a community policy of the same shape is a fifth set of them. So
they are
[[policy.skill]], andpolite-bowneeds no code anywhere.Buttons are config.
[pad]says which of the five one-shot buttons runs which skill,with the prototype's mapping as the default and
paddre-reading within a second.which writes one line, and only the button named:
What makes it safe rather than only convenient
obs[1,61] -> actions[1,14]is refused before anything changes.cost you the one you had.
degraded. Unhealthy would make the update gate roll back every release that followed, over
a config line no release could fix.
obs_len/action_len/model_api, so an incompatiblepolicy is refused before it downloads — which is also where
model_apistops beingdesigned-and-unimplemented. A manifest can refuse and never bless; the shape gate at load is
the real check.
[pad]change restartspadd, neverrobotd. Restarting the daemon that holds a robot upto change what a button does would put a standing robot on the floor.
Wire
API_VERSION16 → 18.robot.policies,robot.loadPolicy,robot.reloadPolicies, apolicy.*namespace onupdaterd(check,install,fetch,search), andSkillbecomesa name rather than a closed enum — nothing outside this workspace consumed it. Refused over
BLE and WebRTC: a daemon update that goes wrong reverts itself, while a gait that goes wrong
walks badly, which nothing detects and only somebody watching the robot can judge.
A correction worth naming
I asserted in a commit message and two route comments that
robot.loadPolicypersists, and builtan argument on it: that it was the riskiest call to open remotely because its effect outlived
everything else. It does not persist —
robotdmutates its in-memory params and reloads, andwriting the file is
robotctl's half ofpolicy load. It is the least durable thing opened oneither transport. Corrected in all three places, and the real finding recorded as open: a gait
chosen from a phone is gone at the next restart, which is the ephemeral "try it until reboot"
mode §3 considered and rejected for the local path, arrived at by accident.
Ten bugs, all found by running it on a robot
Each is its own commit with the reasoning:
policy updatepolicy checkreported a board with nine working policies as having nonepolicy load walk nonepanicked the control thread, and again at every restartpolicy resetsaid it had reset seven slots when it had reset onerobot.doaccepted a skill while the policy was not driving, then dropped it silentlyrobotctl policy do, a command that does not existDocs
docs/design/policy-channel-design.mdis new and owns the mechanism;robotd-design.md§2.3and
updater-design.md§5.5 point at it instead of guessing. The cheat sheet has "Policies andskills" as a walk-through.
Reachable from a phone
robot.do,robot.policies,robot.loadPolicyandrobot.reloadPoliciesare served overboth BLE and WebRTC. Nothing about the safety of any of it changed — the shape gate, the
clamps and the fall reflex are the same whoever asked — but every one of those refusals said it
was waiting for a client, and there is one.
Reading those arms turned up two things.
A skill is not teleop.
robot.dowas grouped withrobot.moveunder BLE's transportargument — a 20-byte notification budget, no link for the first ~73 s of a boot. That argument
is about a stream: fifty small updates a second. A skill is one request, and needs no control
link at all, because the deadman zeroes the twist by itself and a robot nobody is steering stands
still and bows. There is now a test asserting teleop stays refused, so widening that pattern
later cannot quietly take it along.
The authentication runs opposite to the intuition. BLE is the authenticated transport —
PIN-bonded,
encrypt_authenticated_write— and ten metres of radio range means whoever tappedthe button is in the room with the robot by construction, which is the watching condition most
of these refusals turn on. WebRTC has none (§4 of
remote-webrtc.md: any LAN peer inherits whatis opened).
robot.loadPolicyis also the only call here whose effect persists — it writesrobotd.tomland survives a reboot. So the call with the longest-lived effect is the one whoseexposure differs most between the two transports: a reason to sharpen §4, not to withhold the
call from the transport that can show somebody the result.
robot.policiesgains the skill list, which is what makes the rest usable: a client cannotoffer a bow without being told the robot has one.
API_VERSION18 → 19, additive.The console's skill menu was five hardcoded
<option>tags — right when every robot had thesame five, wrong the moment skills became config, and wrong in both directions: missing a bow
somebody added, offering a kick somebody removed. It asks
robot.policiesnow and builds themenu from the answer.
Installing from the Hub stays local-only, for a narrower reason than before: not the blast
radius, which
robot.loadPolicyshares, but thatpolicy.installreaches the network on therobot's behalf and writes the eMMC, where loading points at a file already on it.
The pad bindings had no wire surface at all —
robotctl pad bindedits the config filedirectly, so routing could not help and the methods had to exist first.
pad.bindingsandpad.bindnow do, served byrobotdrather thanconfigdwhich owns the rest of thatnamespace: pairing is about the radio, a binding is about what a button does to the robot, and
checking a name needs the skill list. Routing is per method throughout, so the split costs
nothing.
pad.bindwrites the config file — the first remote call that does, and it has to:paddre-reads
[pad]every second, so a binding held in memory would be reverted before the callerlet go of the phone. Which makes this, not
robot.loadPolicy, the durable remote change.Getting there meant moving the lossless config writer out of
robotctlintorobotd_params::edit, beside the schema it validates against. Three callers write this filenow and a daemon needed the fourth; the guarantee worth keeping is that nothing writes a file
robotdwould refuse to start on, and that is only as good as its least careful writer. Thetwenty tests that exercise the model moved with it —
robotd-paramsis the crate on the recoverypath and shipping a writer there with its tests elsewhere would be the wrong way round.
configure.rsgoes from 1680 lines to 794.A test caught a real bug in the validation.
policies.skillsis not the list of namesrobot.doanswers to:ground_pickandsit_togglehave their own arm of the cascade ratherthan being config entries, so they are absent from it — and validating against it alone rejected
two of the five buttons the pad ships bound to. That list existed inline in
robot.do's ownrefusal; it is
do_namesnow, shared by all three callers.Deliberately not here
--command; a policy needing a live parameter still does not have a home.configurecannot edit the skill table, only list it. A repeating table is not a key witha cursor position.
deciding now that a skill can hold for ten.
policy checkcovers the official set only. A community policy records the commit it camefrom; nothing yet compares that against a moving branch.
🤖 Generated with Claude Code