Adopt spec v0: capability set, typed failures, zone vocabulary/binding split - #104
Open
MJohnson459 wants to merge 5 commits into
Open
Adopt spec v0: capability set, typed failures, zone vocabulary/binding split#104MJohnson459 wants to merge 5 commits into
MJohnson459 wants to merge 5 commits into
Conversation
Adds `mote_bringup/spec/`, Mote's implementation of the Augere v0 specifications' payload layer: `capability.py` (capability/v0) and `mission.py` (mission/v0). Nothing is wired to a wire yet — this is the contract the next commit moves the fleet onto. It lives in `mote_bringup` for the reason `bundle.py` does: three ends need it and none may depend on the others. The task layer executes missions, the fleet agent bridges them, and the off-board server dispatches them with neither ROS nor a checkout. Stdlib-only as well as ROS-free, because the server's container installs no framework. Three of the spec's rules are enforced here rather than left to callers, because each fails silently in a caller. `terminal` is computed from `state` rather than supplied, so a producer cannot get the two out of step. `failure` is refused anywhere but `rejected`/`failed` and required on both. And `recoverable` must be *stated* for `precondition`, `unresolved_zone` and `timeout` — the spec's "depends" rows, where the answer is a fact about the instance and a class-level default would be a guess wearing a contract's clothes. The input validator is a bounded subset of JSON Schema, and the property that makes that acceptable is that it raises on a keyword it does not implement rather than ignoring it: an ignored `maxItems` is a promise nothing keeps, forever, quietly. `check_schema` runs at declaration time, so that failure lands on the platform that wrote the capability. Verified: 27 unit tests, plus `test_spec_conformance.py`, which validates payloads built by these modules against the spec repository's own schemas (all 8 states, all 11 failure classes, command, cancel, capability set) — 6 passed against augereai-spec 3895f02 with jsonschema 4.26. Mote vendors no copy of those schemas; the test skips where the checkout or the library is absent.
Adopts mission/v0 and capability/v0 across the control plane. What a robot is
told to do is now a capability key plus a typed input; what comes back carries
a failure class and a recoverability instead of a sentence. That is a change of
meaning in an existing payload, so by protocol.py's own rule the topic root
moves: `mote/v2/<robot_id>/{presence,health,pose,capabilities,mission/*}`.
Telemetry moved with it unchanged — a tree has one version, not one per leaf.
A v1 robot and a v2 server do not interoperate, deliberately: a translating
shim would be a third definition of the wire.
**The capability set replaces the grammar.** `mote_tasks/capabilities.py`
declares `goto` and `fetch` as standard-registry keys with the registry's own
property names (`fetch` delivers to `destination`). The task server publishes it
latched; the agent forwards it retained, never authors it, so a robot whose task
server is down advertises nothing — which is true. A location input `$ref`s
zone/v0's zone reference, and the dashboard's dispatch form is *generated* from
that: a select of the keys, one field per property, a zone picker exactly where
the `$ref` says the property is a place. The page now holds no list of
capabilities and no list of which inputs are zones.
**Failures are typed.** `rejected: busy with '…'` became class `busy`,
`recoverable: true`, with the in-flight mission's id in the detail. Which class
a tree failure gets is decided by the behaviour that failed, because only it
knows: Nav2 refusing a goal is `unreachable`, Nav2 aborting after its own
recoveries is `obstructed` and retryable, no action server at all is `internal`.
**The lane moved to the executor**, which is the thing that actually holds it
and which also sees missions issued locally on the robot. It was the agent's
only because `task/command` had no correlation id. dispatch.py keeps what only
it can answer — dedup, an hour of retention so a restarted dispatcher learns
outcomes, the no-verdict timeout, and `source` — and its prose parser is gone.
**Preconditions are evaluated, not documented.** `localized` wants a
map->base_link transform newer than 5 s; `zone_known` carries zone/v0's own
reason. A `goto` sent to an unlocalised robot is refused with a reason instead
of flailing in Nav2. `max_duration_s` is enforced too. An unmet *non-blocking*
precondition lands in `warnings` on the accepted status rather than refusing.
The ROS seam stays `std_msgs/String`; the string is JSON, so the agent still
forwards bytes and cannot reinterpret them. That cost the bench flow, so
`ros2 run mote_tasks mission goto target=kitchen` replaces typing a sentence
into `ros2 topic pub`; `--list` prints what the robot will accept.
Also removes `mote_fleet/schema/{command,status}.schema.json`: those payloads
are the specification's now, and a stale local mirror would be read as
authoritative by whoever found it next.
Verified: 296 passed / 7 skipped in the robot environment, and 255 passed in the
dev environment where mosquitto exists — which is what runs the end-to-end
acceptance. `test_e2e_fleet.py` now covers the capability set reaching the
broker, a typed dispatch through the real behaviour tree, and `unresolved_zone`,
`invalid_input` and `busy` coming back classified with the mission holding the
lane named in the detail. Two defects the new tests found and fixed: the
tracker looked an id up before expiring the retention window, so a forgotten
mission still answered as a duplicate; and the fake robot rejected an unknown
capability before publishing `dispatched`, which the spec makes the only state
`rejected` is reachable from.
`zones.yaml` held names and coordinates in one file. zone/v0 pulls them apart, and the split is what the whole spec is for: names are shared, coordinates are not, maps are never shared. A floor is now two documents — `vocabulary.yaml` (site, floor, and what the places are *called*) and `binding.yaml` (this robot's poses and footprints, stamped with `platform_id`, `frame_id` and `map_revision`). Both are built by the new `mote_bringup/spec/zone.py`, which also takes over the vocabulary rules and the containment geometry from `bundle.py` and `mote_tasks.zones`, so the robot and the fleet server give the same answer on a boundary case. **The split is structural, not a rule to remember.** The vocabulary is built from the fields a vocabulary may carry, never stripped of the ones it may not: stripping holds only until someone adds a geometry key and forgets, and the leak would be a plausible-looking coordinate rather than a crash. Three tests assert it by walking whole documents for geometry-shaped keys. **What it buys is a distinction the robot could not draw.** A name in the floor's vocabulary that this robot has never been taught now resolves `unbound` — "I know that place, nobody has driven me there" — where before it was `unknown_name`, which sent an operator hunting for a typo that was not there. The refusal says to drive there and run `save-zone`. `task_server` loads with `load_floor` (vocabulary ∪ binding) rather than `load_zones` (bound only) for exactly that, and logs the untaught names at startup. A binding whose name the vocabulary does not carry is a local extension: usable here, never advertised, because one robot must not invent shared vocabulary for its neighbours. **Migration is a side effect of writing, never a step.** `bundle.read_floor` takes a floor directory or a legacy combined file, and reads the latter through `zone.split` so both paths produce the same structure by construction. The first `save-zone` or `segment-map --write` on an old floor writes the pair and keeps the original as `zones.yaml.premigration`. The sim worlds' committed `<world>.zones.yaml` files stay combined on purpose — one file is the right shape for a fixture with one robot in it. **Which half travels where.** A revision carries the binding, because a coordinate means nothing without the frame beside it; installing a pulled revision replaces the floor's binding and leaves the vocabulary alone, so re-mapping no longer costs an operator the aliases they typed. A candidate from the zone editor carries both halves and promotion lifts its vocabulary to the floor: uploading is not publishing, applied to names as well as coordinates. Two things the specification decided rather than taste. A polygon-only zone's binding pose is now **derived once, on write** rather than by every reader — zone/v0 requires a binding to carry a pose, and it is right to, since a footprint alone cannot say where a mission navigates to. And a floor is created with **no zone documents at all** rather than empty ones: seeding empties made a hand-written `zones.yaml` dropped in beside them ambiguous, and the empty pair won silently. Still unanswerable here and stated as such: `wrong_floor` (the robot holds one floor at a time) and `stale_revision` (the bundle declares no frame continuity, which zone/v0 puts out of its own scope too). Broadcasting the vocabulary over the retained registry subtree is M6's, and is what this split was the prerequisite for. Verified: 708 passed / 10 skipped across mote_bringup, mote_tasks and mote_fleet in the dev environment, which is the tier with a real mosquitto — so the map registry's publish/promote/pull end-to-end ran against the new layout. `test_spec_conformance.py` validates all four zone/v0 documents against the specification's own schemas (10 passed with jsonschema 4.26 against augereai-spec 3895f02). One conformance finding is recorded rather than papered over: the spec requires a pose on every binding and Mote's polygon-only zones had none — fixed on Mote's side, since the spec is right. Also fixes a collection bug this work uncovered: a module-level `pytest.importorskip` raises `Skipped`, which derives from `BaseException`, and the launch_testing plugin this workspace loads imports test modules itself outside pytest's skip handling — so one such skip aborted collection of the whole `mote_bringup/test` directory, reporting "no tests collected" rather than an error. 412 tests were silently not running.
The resolution that put it above `statusText` left the original copy inline at the call site. Two identical paragraphs about one rule is one to keep in step with the code and one to forget.
MJohnson459
force-pushed
the
spec-v0-adoption
branch
from
August 25, 2026 12:57
598f1f4 to
eb5f73c
Compare
**`fleet-image`** could not find `mote_bringup/mote_bringup/spec/`. The
`.dockerignore` denies everything and allows the files each image copies, and
its own comment says so — "adding a COPY means adding a line here". I added the
COPY and not the line. Both lines, in fact: `*` excludes the directory itself,
so docker never descends into it and a bare `!dir/` allows nothing.
That rule lived only in a comment, which is why it was possible to forget, so
`test_deploy_config.py` now enforces it: every COPY source in both Dockerfiles
must be covered by an allowlist entry, and any allowed directory must carry a
`/**` beside it. It checks *this file's own convention* rather than emulating
docker's matcher — a second implementation of `.dockerignore` semantics would
be a worse thing to own than the rule it was checking. Reverting either fix
reproduces its failure.
**`build`** collected `test_mapsync.py`, which imported `mote_tasks.zones` for
the new split-floor test — and that imports `geometry_msgs`. `mote_fleet/test`
runs in the `fleet` environment, which has no ROS *by design*: it is the
deployable control-plane role, and keeping it ROS-free is the property the
package split exists to hold. The test now writes its floor with
`bundle.write_floor`, the ROS-free writer the fleet server itself uses, which
is the correct side of that seam anyway.
**And the e2e test was racy**, which running it again is what surfaced. The
`busy` assertion waited for *any* status for the second mission and then read
the last one — but the agent publishes `dispatched` as it forwards and the
executor's refusal follows milliseconds later, so it caught `dispatched` on a
loaded machine and `rejected` on an idle one. It waits for a terminal status
now, as every other assertion in the file already did; that one was the sole
exception. The robot's behaviour was never in question — the failing run's log
carries the correct refusal, naming the mission holding the lane.
Verified: the fleet image builds locally for the first time on this branch, and
the container answers `/healthz` with `{"contract": "mote/v2"}` — the spec
modules import inside it, which a file being present does not prove. 226
passed / 4 skipped under the ROS-free `fleet` environment, the job that failed.
The e2e test five times consecutively, then 712 passed / 10 skipped in the dev
environment.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adopts the three Augere v0 specifications across Mote's control plane, in three
independently reviewable commits.
1 — the contracts as shared modules (
mote_bringup/spec/).mission.py,capability.pyandzone.pyimplement the specs' payloads, ROS-free andstdlib-only, in
mote_bringupfor the reasonbundle.pyis there: the tasklayer executes missions, the agent bridges them and the off-board server
dispatches them, and none of the three may depend on the others. Three rules
are enforced here rather than left to callers because each fails silently in a
caller —
terminalis computed fromstaterather than supplied,failureisrefused anywhere but
rejected/failed, andrecoverablemust be statedfor the spec's three "depends" classes, where a class-level default would be a
guess wearing a contract's clothes. The input validator is a bounded subset of
JSON Schema whose acceptable property is that it raises on a keyword it does
not implement: an ignored
maxItemsis a promise nothing keeps, quietly,forever.
2 — a capability set and typed failures on the wire (
mote/v2). A robot istold a capability key and a typed input, not
fetch red_box dropoff; a refusalcarries a class and a recoverability, not a sentence. That is a change of
meaning in an existing payload, so by
protocol.py's own rule the topic rootmoves. The capability set is published by the task server and forwarded by the
agent — never authored — so a robot whose task server is down advertises
nothing, which is true; the dashboard's dispatch form is generated from it, with
a zone picker exactly where a property's schema
$refs zone/v0's zonereference, so the page holds no list of capabilities and no list of which inputs
are places. The lane moved to the executor, which is the thing that actually
holds it;
dispatch.pykept dedup, an hour of retention, the no-verdicttimeout and
source, and lost its prose parser. Preconditions are nowevaluated rather than documented: a
gotosent to an unlocalised robot isrefused with a reason instead of flailing in Nav2.
3 — the zone vocabulary/binding split. A floor is two documents:
vocabulary.yaml(what the places are called — no coordinates, safe tobroadcast) and
binding.yaml(this robot's poses, stamped withplatform_id,frame_idandmap_revision). The vocabulary is built from the fields it maycarry, never stripped of the ones it may not, because stripping holds only until
someone adds a geometry key and forgets. What it buys is a distinction the robot
could not draw: a name in the vocabulary this robot has never been taught now
resolves
unbound— "I know that place, nobody has driven me there" — wherebefore it was
unknown_name, which sent an operator hunting for a typo that wasnot there. Migration is a side effect of writing, never a step. A revision
carries the binding; a candidate from the zone editor carries both halves and
promotion lifts its vocabulary to the floor, so "uploading is not publishing"
now covers names as well as coordinates.
Verification
710 passed, 10 skipped across
mote_bringup,mote_tasksandmote_fleetinthe dev environment — the tier with a real mosquitto, so the M1 acceptance
(
test_e2e_fleet.py) and the map registry's publish/promote/pull ran againstthe new wire and the new layout end to end.
test_e2e_fleet.pynow also coversthe capability set reaching the broker and
unresolved_zone,invalid_inputand
busycoming back classified with the mission holding the lane named.test_spec_conformance.pyvalidates payloads built by these modules againstthe specification's own JSON Schemas — all 8 mission states, all 11 failure
classes, command, cancel, the capability set, and all four zone documents. 10
passed against
augereai-spec3895f02 with jsonschema 4.26. Mote vendors nocopy of those schemas; the test skips where the checkout or the library is
absent.
One conformance finding is recorded rather than papered over: zone/v0 requires
a pose on every binding, and Mote's polygon-only zones (what
segment-mapemits) had none. Fixed on Mote's side — the spec is right, since a binding is
where a mission navigates to and a footprint alone cannot say. The pose is now
derived once on write rather than by every reader.
Three defects the new tests found, all fixed here:
window, so a mission the retention rule says was forgotten still answered as
a duplicate;
dispatched, which the spec makes the only staterejectedis reachablefrom;
pytest.importorskipraises
Skipped, which derives fromBaseException, and thelaunch_testingplugin this workspace loads imports test modules outside pytest's skip
handling — so one such skip aborted collection of the entire
mote_bringup/testdirectory and reported "no tests collected" with exitcode 0. 412 tests were silently not running. Filed as a follow-up (#504)
because nothing stops the next one.
Not done, deliberately
Broadcasting the vocabulary over the retained registry subtree — what makes a
second robot at a site cheap — is filed as #503. The split is its prerequisite
and is what this task was asked for; the broadcast is M6's own work and is
independently landable, which is how the three changes here were scoped.
Also left:
wrong_floorandstale_revisionremain unanswerable and are statedas such — the robot holds one floor at a time, and the map bundle declares no
frame continuity, which zone/v0 puts out of its own scope too.
running,blockedandcancelledare optional in mission/v0 and Mote emits none ofthem, which is conforming;
mission/cancelis a reserved topic with nopublisher, because the task layer has no cancel and
cancellable: truewouldpromise a message nothing handles.
A v1 robot and a v2 server do not interoperate. Nothing translates between
them, deliberately: a shim would be a third definition of the wire. Upgrade the
agent and the server together; identity and maps are unaffected, since neither
is on this tree.