Skip to content

The policy channel: policies from the Hub, and skills from config - #191

Open
pierre-rouanet wants to merge 47 commits into
mainfrom
policy-hub-design
Open

The policy channel: policies from the Hub, and skills from config#191
pierre-rouanet wants to merge 47 commits into
mainfrom
policy-hub-design

Conversation

@pierre-rouanet

@pierre-rouanet pierre-rouanet commented Sep 1, 2026

Copy link
Copy Markdown
Member

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

robotctl policy list                     # what each slot runs, and what you changed
robotctl policy check                    # is there a newer official set
sudo robotctl policy update              # install one, forwards or back
sudo robotctl policy load walk <file>    # try your own, live
robotctl policy search microduck         # what else is out there
sudo robotctl policy load walk <org/repo>    # try a stranger's
sudo robotctl policy add polite-bow <org/repo>   # add it as a skill instead
robotctl robot do polite-bow
sudo robotctl pad bind x polite-bow      # and put it on a button
sudo robotctl policy reset               # put it all back
robotctl configure --list                # what does this robot change at all

The five pieces

Loading is a config edit plus a live reload. [policy] walk was already an
Option<PathBuf> where unset means "this mode's default", so load writes that key and
reset removes it — persistence and undo are the mechanism that already existed. The swap
happens at the home pose with torque on throughout, on the path robot.setMode has used
since v15.

The official set left the artifact. robotd reads /opt/robot/policies/current, which
scripts/seed-policies.sh fills by downloading the pinned set from
pollen-robotics/microduck-policies. policies/ and its three --include lists are gone:
6.8 MB out of every daemon release, and CI builds from ~6 min to ~3.

The set describes itself. A manifest.json on the Hub says what it contains and what
each 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 file entries are the download list. The three episodic ones become skills, which
reproduces today's built-in three exactly — the check that it is right rather than merely
plausible. scripted keeps the ground pick out of the skill list: it writes a phase over time,
and calling it episodic would feed a scripted policy an all-zero command.

So adding a policy for every robot is: upload the .onnx, add one entry, tag it, and
robotctl policy update. Not checked into this repo on purpose — a copy 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.

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]], and polite-bow needs 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 padd re-reading within a second.

$ robotctl pad bindings
a           ground_pick
x           roulade
lb          kick_left
rb          kick_right
dpad_down   sit_toggle

$ sudo robotctl pad bind x polite-bow
x runs polite-bow
  padd picks this up within a second

which writes one line, and only the button named:

[pad]
x = "polite-bow"

What makes it safe rather than only convenient

  • A policy that is not obs[1,61] -> actions[1,14] is refused before anything changes.
  • A load that fails anyway keeps the controller that was running. Trying a gait cannot
    cost you the one you had.
  • A missing override at the next boot costs its slot, not the robot: it falls back and reports
    degraded. Unhealthy would make the update gate roll back every release that followed, over
    a config line no release could fix.
  • Community manifests already carry obs_len/action_len/model_api, so an incompatible
    policy is refused before it downloads — which is also where model_api stops being
    designed-and-unimplemented. A manifest can refuse and never bless; the shape gate at load is
    the real check.
  • A [pad] change restarts padd, never robotd. Restarting the daemon that holds a robot up
    to change what a button does would put a standing robot on the floor.

Wire

API_VERSION 16 → 18. robot.policies, robot.loadPolicy, robot.reloadPolicies, a
policy.* namespace on updaterd (check, install, fetch, search), and Skill becomes
a 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.loadPolicy persists, and built
an argument on it: that it was the riskiest call to open remotely because its effect outlived
everything else. It does not persist — robotd mutates its in-memory params and reloads, and
writing the file is robotctl's half of policy load. 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.

Ten bugs, all found by running it on a robot

Each is its own commit with the reasoning:

  • a daemon update would have silently reverted a gait chosen with policy update
  • policy check reported a board with nine working policies as having none
  • policy load walk none panicked the control thread, and again at every restart
  • policy reset said it had reset seven slots when it had reset one
  • a switched-off slot rendered identically to one the robot never had
  • "newest v2 — up to date", with v3 listed underneath it
  • two of four per-skill overrides were decoration, one of them never read at all
  • robot.do accepted a skill while the policy was not driving, then dropped it silently
  • rebinding a button took a daemon restart, and there was no way to put it back
  • the tool printed robotctl policy do, a command that does not exist

Docs

docs/design/policy-channel-design.md is new and owns the mechanism; robotd-design.md §2.3
and updater-design.md §5.5 point at it instead of guessing. The cheat sheet has "Policies and
skills" as a walk-through.

Reachable from a phone

robot.do, robot.policies, robot.loadPolicy and robot.reloadPolicies are served over
both 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.

duckctl policy list
duckctl do roulade
duckctl policy load walk /opt/robot/policies/current/alpha_walking.onnx
duckctl policy reset walk

Reading those arms turned up two things.

A skill is not teleop. robot.do was grouped with robot.move under BLE's transport
argument — 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 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 none (§4 of remote-webrtc.md: any LAN peer inherits what
is opened). 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: a reason to sharpen §4, not 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. API_VERSION 18 → 19, additive.

The console's skill menu was five hardcoded <option> tags — right when every robot had the
same 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.policies now and builds the
menu from the answer.

Installing from the Hub stays local-only, 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.

The pad bindings had no wire surface at allrobotctl pad bind edits the config file
directly, so routing could not help and the methods had to exist first. pad.bindings and
pad.bind now do, served by robotd rather than configd which owns the rest of that
namespace: 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.

duckctl pad bindings
duckctl pad bind x polite-bow
duckctl pad reset x

pad.bind writes the config file — the first remote call that does, and 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.

Getting there meant moving the lossless config writer out of robotctl into
robotd_params::edit, beside the schema it validates against. Three callers write this file
now and a daemon needed the fourth; the guarantee worth keeping is that nothing writes a file
robotd would refuse to start on, and that is only as good as its least careful writer. The
twenty tests that exercise the model moved with it — robotd-params is the crate on the recovery
path and shipping a writer there with its tests elsewhere would be the wrong way round.
configure.rs goes from 1680 lines to 794.

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 — and validating against it alone rejected
two of the five buttons the pad ships bound to. That list existed inline in robot.do's own
refusal; it is do_names now, shared by all three callers.

Deliberately not here

  • A generic slot for a policy with its own command encoding. Flamingo works as a skill with
    --command; a policy needing a live parameter still does not have a home.
  • configure cannot edit the skill table, only list it. A repeating table is not a key with
    a cursor position.
  • A running skill has no fall reflex for its whole duration. Fine at half a second, worth
    deciding now that a skill can hold for ten.
  • policy check covers the official set only. A community policy records the commit it came
    from; nothing yet compares that against a moving branch.

🤖 Generated with Claude Code

… 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]
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Coverage

70.67% lines on this branch, against a floor of 70%.

Per-file
Filename                              Regions    Missed Regions     Cover   Functions  Missed Functions  Executed       Lines      Missed Lines     Cover    Branches   Missed Branches     Cover
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
btd/src/adv.rs                             79                 0   100.00%           9                 0   100.00%          42                 0   100.00%           0                 0         -
btd/src/bluez.rs                          422               422     0.00%          32                32     0.00%         279               279     0.00%           0                 0         -
btd/src/chorale.rs                        346               246    28.90%          23                13    43.48%         230               171    25.65%           0                 0         -
btd/src/framing.rs                        217                 7    96.77%          17                 1    94.12%          97                 5    94.85%           0                 0         -
btd/src/gatt.rs                             3                 0   100.00%           1                 0   100.00%           3                 0   100.00%           0                 0         -
btd/src/link.rs                            13                 0   100.00%           1                 0   100.00%          17                 0   100.00%           0                 0         -
btd/src/main.rs                            77                77     0.00%          11                11     0.00%          77                77     0.00%           0                 0         -
btd/src/pairing.rs                        149                 2    98.66%          12                 1    91.67%          81                 1    98.77%           0                 0         -
btd/src/route.rs                          320                30    90.62%          26                 0   100.00%         313                10    96.81%           0                 0         -
btd/src/session.rs                       1118                71    93.65%          66                 5    92.42%         649                41    93.68%           0                 0         -
btd/src/upstream.rs                       297                43    85.52%          29                 8    72.41%         184                25    86.41%           0                 0         -
configd/src/bluez.rs                      441               441     0.00%          78                78     0.00%         297               297     0.00%           0                 0         -
configd/src/identity.rs                   111                 5    95.50%          12                 1    91.67%          52                 3    94.23%           0                 0         -
configd/src/main.rs                       494               494     0.00%          38                38     0.00%         338               338     0.00%           0                 0         -
configd/src/net.rs                        246                12    95.12%          38                 2    94.74%         181                 4    97.79%           0                 0         -
configd/src/nm.rs                         271               213    21.40%          43                39     9.30%         171               120    29.82%           0                 0         -
configd/src/pad.rs                        301                12    96.01%          33                 2    93.94%         186                 8    95.70%           0                 0         -
configd/src/power.rs                       30                30     0.00%           6                 6     0.00%          28                28     0.00%           0                 0         -
configd/src/store.rs                      350                25    92.86%          28                 2    92.86%         173                 9    94.80%           0                 0         -
configd/src/units.rs                      119               119     0.00%          18                18     0.00%          83                83     0.00%           0                 0         -
duck-control/src/bus.rs                   408               299    26.72%          33                21    36.36%         276               203    26.45%           0                 0         -
duck-control/src/fall.rs                  173                 0   100.00%          15                 0   100.00%         108                 0   100.00%           0                 0         -
duck-control/src/imu.rs                   370                 8    97.84%          20                 0   100.00%         195                 5    97.44%           0                 0         -
duck-control/src/io.rs                    127                19    85.04%          20                 5    75.00%         115                17    85.22%           0                 0         -
duck-control/src/model.rs                 113                 1    99.12%          14                 0   100.00%          68                 0   100.00%           0                 0         -
duck-control/src/obs.rs                   260                 3    98.85%          23                 1    95.65%         174                 5    97.13%           0                 0         -
duck-control/src/policy.rs                370               229    38.11%          45                25    44.44%         253               148    41.50%           0                 0         -
duck-control/src/safety.rs                410                12    97.07%          31                 2    93.55%         274                 6    97.81%           0                 0         -
duck-detect/src/bin/duck-bench.rs         306               306     0.00%          16                16     0.00%         170               170     0.00%           0                 0         -
duck-detect/src/lib.rs                    512                10    98.05%          24                 0   100.00%         274                 6    97.81%           0                 0         -
duck-detect/src/onnx.rs                    96                96     0.00%           5                 5     0.00%          52                52     0.00%           0                 0         -
duck-detect/src/rknn.rs                   267               237    11.24%          15                11    26.67%         216               195     9.72%           0                 0         -
duck-ipc-proto/src/lib.rs                1935               154    92.04%         127                13    89.76%        1418                88    93.79%           0                 0         -
duckctl/src/main.rs                      1991               825    58.56%         126                41    67.46%        1241               508    59.07%           0                 0         -
kinematics/src/hand.rs                    373                 2    99.46%          21                 2    90.48%         172                 2    98.84%           0                 0         -
kinematics/src/head.rs                    381                 9    97.64%          20                 0   100.00%         188                 4    97.87%           0                 0         -
kinematics/src/lib.rs                     229                11    95.20%          19                 2    89.47%         115                 7    93.91%           0                 0         -
kinematics/src/math.rs                    171                 0   100.00%          16                 0   100.00%          79                 0   100.00%           0                 0         -
kinematics/src/mjcf.rs                    225                32    85.78%          17                 1    94.12%         132                15    88.64%           0                 0         -
kinematics/src/tof.rs                     338                20    94.08%          14                 1    92.86%         184                13    92.93%           0                 0         -
mediad/src/config.rs                       90                 5    94.44%           7                 1    85.71%          46                 4    91.30%           0                 0         -
mediad/src/detect.rs                      293               222    24.23%          13                 9    30.77%         200               145    27.50%           0                 0         -
mediad/src/exposure.rs                    412               161    60.92%          28                 8    71.43%         279               110    60.57%           0                 0         -
mediad/src/main.rs                        245               245     0.00%           7                 7     0.00%         158               158     0.00%           0                 0         -
mediad/src/pipeline.rs                    977               909     6.96%          62                56     9.68%         624               578     7.37%           0                 0         -
mediad/src/producer.rs                    161                33    79.50%          20                 5    75.00%         109                20    81.65%           0                 0         -
mediad/src/route.rs                       141                19    86.52%          10                 0   100.00%         117                 8    93.16%           0                 0         -
mediad/src/session.rs                     493                12    97.57%          30                 0   100.00%         290                 8    97.24%           0                 0         -
mediad/src/upstream.rs                    123                23    81.30%          10                 1    90.00%          82                17    79.27%           0                 0         -
mediad/src/web.rs                         112                26    76.79%          14                 4    71.43%          76                17    77.63%           0                 0         -
odometry/src/lib.rs                       321                 5    98.44%          22                 1    95.45%         192                 7    96.35%           0                 0         -
padd/src/main.rs                          498               498     0.00%          11                11     0.00%         338               338     0.00%           0                 0         -
padd/src/tap.rs                           693               455    34.34%          42                25    40.48%         419               267    36.28%           0                 0         -
pet-detect/src/bin/detect.rs               61                61     0.00%           1                 1     0.00%          38                38     0.00%           0                 0         -
pet-detect/src/bin/features.rs             46                46     0.00%           2                 2     0.00%          20                20     0.00%           0                 0         -
pet-detect/src/lib.rs                     358               180    49.72%          27                13    51.85%         217               115    47.00%           0                 0         -
pet-detect/src/worker.rs                  293               293     0.00%          17                17     0.00%         223               223     0.00%           0                 0         -
robotctl/src/configure.rs                2157               443    79.46%         121                24    80.17%        1088               276    74.63%           0                 0         -
robotctl/src/duck.rs                     1211               144    88.11%          63                 1    98.41%         604                47    92.22%           0                 0         -
robotctl/src/main.rs                     4958              2546    48.65%         285               133    53.33%        3293              1698    48.44%           0                 0         -
robotctl/src/monitor.rs                  4024               724    82.01%         213                35    83.57%        2522               464    81.60%           0                 0         -
robotctl/src/path_map.rs                  425                59    86.12%          24                 2    91.67%         223                36    83.86%           0                 0         -
robotctl/src/show.rs                      714                46    93.56%          31                 1    96.77%         509                25    95.09%           0                 0         -
robotd-params/src/lib.rs                 1438               110    92.35%         132                12    90.91%        1055                76    92.80%           0                 0         -
robotd-params/src/registry.rs             184                10    94.57%          13                 2    84.62%         138                17    87.68%           0                 0         -
robotd/src/chorale.rs                    1366                30    97.80%          71                 4    94.37%         826                20    97.58%           0                 0         -
robotd/src/control.rs                     348               312    10.34%          22                17    22.73%         279               237    15.05%           0                 0         -
robotd/src/intents.rs                     400                71    82.25%          45                 9    80.00%         294                48    83.67%           0                 0         -
robotd/src/main.rs                       5545              1550    72.05%         249                49    80.32%        3513              1015    71.11%           0                 0         -
robotd/src/soc.rs                          47                25    46.81%           4                 1    75.00%          30                15    50.00%           0                 0         -
robotd/src/sound.rs                       968               688    28.93%          50                33    34.00%         565               416    26.37%           0                 0         -
robotd/src/theremin.rs                    472                71    84.96%          32                 6    81.25%         271                38    85.98%           0                 0         -
sounds/src/chorale/beat.rs                403                16    96.03%          26                 1    96.15%         246                12    95.12%           0                 0         -
sounds/src/chorale/midi.rs               1047                80    92.36%          44                 2    95.45%         581                43    92.60%           0                 0         -
sounds/src/chorale/mod.rs                1423                57    95.99%          83                 3    96.39%         812                41    94.95%           0                 0         -
sounds/src/chorale/text.rs                700                60    91.43%          41                12    70.73%         376                17    95.48%           0                 0         -
sounds/src/lib.rs                         162                30    81.48%          14                 6    57.14%          84                18    78.57%           0                 0         -
sounds/src/main.rs                        463               463     0.00%          20                20     0.00%         240               240     0.00%           0                 0         -
sounds/src/personality.rs                 174                 0   100.00%           6                 0   100.00%          84                 0   100.00%           0                 0         -
sounds/src/rng.rs                         173                 0   100.00%          18                 0   100.00%          96                 0   100.00%           0                 0         -
sounds/src/stream.rs                      719                 7    99.03%          46                 1    97.83%         420                 6    98.57%           0                 0         -
sounds/src/synth.rs                       353                 7    98.02%          29                 0   100.00%         197                 3    98.48%           0                 0         -
sounds/src/voices.rs                      679                 2    99.71%          26                 0   100.00%         353                 2    99.43%           0                 0         -
test-support/src/lib.rs                   272                 2    99.26%          22                 0   100.00%         167                 0   100.00%           0                 0         -
tof/src/lib.rs                             88                 0   100.00%           9                 0   100.00%          54                 0   100.00%           0                 0         -
tof/src/main.rs                           478               438     8.37%          23                18    21.74%         303               281     7.26%           0                 0         -
tof/src/sensor.rs                         230               148    35.65%          22                15    31.82%         163               114    30.06%           0                 0         -
tof/src/status.rs                          76                 2    97.37%           7                 1    85.71%          58                 1    98.28%           0                 0         -
updater/src/config.rs                     428                24    94.39%          37                 4    89.19%         349                17    95.13%           0                 0         -
updater/src/engine.rs                    3234               468    85.53%         215                27    87.44%        2090               286    86.32%           0                 0         -
updater/src/faults.rs                      69                 7    89.86%           7                 0   100.00%          51                 0   100.00%           0                 0         -
updater/src/fsutil.rs                     104                30    71.15%          10                 6    40.00%          51                24    52.94%           0                 0         -
updater/src/hooks.rs                      454                15    96.70%          37                 2    94.59%         382                12    96.86%           0                 0         -
updater/src/ipc.rs                        855               297    65.26%          65                19    70.77%         539               163    69.76%           0                 0         -
updater/src/journal.rs                    832                78    90.62%          57                10    82.46%         483                53    89.03%           0                 0         -
updater/src/lib.rs                         43                13    69.77%           4                 0   100.00%          33                13    60.61%           0                 0         -
updater/src/main.rs                       570               242    57.54%          38                14    63.16%         425               156    63.29%           0                 0         -
updater/src/manifest.rs                   159                 6    96.23%          16                 0   100.00%         110                 1    99.09%           0                 0         -
updater/src/orphan.rs                     288                 6    97.92%          25                 0   100.00%         181                 3    98.34%           0                 0         -
updater/src/policy.rs                    1165               517    55.62%          95                45    52.63%         623               291    53.29%           0                 0         -
updater/src/preflight.rs                  382                16    95.81%          47                 4    91.49%         284                11    96.13%           0                 0         -
updater/src/reconcile.rs                  292                15    94.86%          25                 3    88.00%         172                 5    97.09%           0                 0         -
updater/src/robot.rs                      219                27    87.67%          34                 7    79.41%         133                15    88.72%           0                 0         -
updater/src/source/github.rs              474               169    64.35%          51                26    49.02%         304                99    67.43%           0                 0         -
updater/src/source/hf_hub.rs              136                72    47.06%          20                12    40.00%          81                41    49.38%           0                 0         -
updater/src/source/http.rs                319                82    74.29%          29                11    62.07%         260                88    66.15%           0                 0         -
updater/src/source/local.rs               346                37    89.31%          36                 9    75.00%         192                26    86.46%           0                 0         -
updater/src/source/mod.rs                  39                30    23.08%           4                 2    50.00%          30                24    20.00%           0                 0         -
updater/src/spawn.rs                       94                13    86.17%           5                 0   100.00%          49                 4    91.84%           0                 0         -
updater/src/store.rs                      593                41    93.09%          43                 7    83.72%         271                42    84.50%           0                 0         -
updater/src/transcript.rs                 518                42    91.89%          31                 3    90.32%         290                22    92.41%           0                 0         -
updater/src/verify.rs                     786                95    87.91%          55                18    67.27%         424                75    82.31%           0                 0         -
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
TOTAL                                   63872             18198    71.51%        4242              1201    71.69%       39864             11693    70.67%           0                 0         -

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]
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]
@pierre-rouanet pierre-rouanet changed the title The policy channel: policies from the Hub The policy channel: policies from the Hub, and skills from config Sep 1, 2026
pierre-rouanet and others added 16 commits September 1, 2026 18:00
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]
pierre-rouanet and others added 3 commits September 2, 2026 17:31
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]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants