Fleet API — interface contract v1¶
The HTTP wire: enrollment, the registry, mediated dispatch, the audit log,
and what the dashboard needs to bootstrap. This is the second of the fleet's two
contracts — control-plane.md specifies the MQTT one — and
the versioned spec fleet.md requires M3 to publish.
| Contract version | v1 (routes under /v1/…, payload schema: 1) |
| Authority | mote_fleet/server/fleet_server.py + bundle_store.py |
| Kept honest by | test_fleet_server.py, test_map_registry.py, test_mapsync.py; test_e2e_fleet.py for dispatch end to end |
| Milestone | M3, extended by M4 (the registry routes). Operator runbook: README.md §6–9 and §11. Measurements: m3-verification.md, m4-verification.md |
Why there are two contracts¶
They carry opposite directions of the same loop and have opposite requirements.
Reads ride MQTT. Presence, health, pose and task status are retained on the
broker, so any subscriber — fleetctl watch, the dashboard, a future tool —
sees the whole fleet's current state the moment it connects, with no polling and
no service in the middle. The browser speaks the same protocol as everything
else, over WebSockets.
Except for the client that asks once. A stream is the right shape for a
dashboard and the wrong one for a tool that reads a robot's state, acts, and
exits: it would have to speak MQTT, track the topic tree, know which topics are
retained, and hold a broker credential — three contracts to keep up with where
one would do. So this server subscribes on such a client's behalf and serves
what it last saw at GET /v1/robots/<id>. The
distinction is how often, not what: the payloads are the same documents,
forwarded, and anything wanting every transition still joins the broker.
Writes ride HTTP. A command has to be attributed to somebody, recorded, and
refusable. Broker ACLs can express "may publish" but not "who did", so dispatch
is a request to this API, which authorizes the operator, writes the audit row,
and only then publishes to the topic tree it would otherwise have written to
directly. The topic tree does not change — fleetctl moved to this route in
M3 and no robot noticed.
Versioning¶
The same two-axis rule as the control plane. The path carries the major
version: a breaking change ships as /v2/… and both can be served while
clients migrate. Every payload carries schema, an integer tracking the body
shape within a major version; consumers must ignore fields they do not
recognise, so adding an optional field bumps nothing. Removing a field, renaming
one, or changing its type is a v2 change.
Status codes are part of the contract: a client may switch on them.
Authentication¶
Every /v1 route needs an operator token except those named below. The
check is one gate in front of route dispatch, not a line in each handler, so a
route added later is authenticated by default and has to opt out in a place a
reviewer reads. An anonymous caller is refused before the route table is
consulted for existence: an unknown path answers 401, never a 404 that would
say which routes are real.
| Route | Credential |
|---|---|
everything under /v1 |
an operator token as Authorization: Bearer <token> |
GET /healthz |
none — a liveness probe that needs a secret is one nobody wires up |
the static UI (/, /*.mjs, …) |
none — the page has to load in order to ask for a token, and holds no fleet data until it has one |
POST /v1/enroll |
an enrollment token in the body (single-use by default) — a robot is not an operator, and an unattended first boot has no human behind it |
POST …/revisions/<rev> (map upload) and GET …/revisions/<rev>/bundle.tar.gz (pull) |
none, but an upload's robot_id must be enrolled — see the registry |
Operator tokens are minted on the fleet box, against the registry file, never over the network:
pixi run -e fleet fleetctl -- operator new --name michael
pixi run -e fleet fleetctl -- operator list
pixi run -e fleet fleetctl -- operator revoke --token <token>
The token's name is what the audit log records, which is why an unnamed one is refused. Revocation keeps the row: who had access is part of the record.
Bearer header only — never a query parameter, which would put the credential in every access log between here and the browser.
The two robot routes are the carve-out that costs something, and it is deliberate. M4's rule is that uploading is not publishing: a candidate changes nothing about any floor until an operator promotes it, so the upload is bounded, audited and inert, and the pull serves only what an operator has already promoted. The alternative today is a credential robots do not have — issuing one at enrollment is its own piece of work, and until it lands these two routes are what the tailnet protects rather than what the API does.
Security posture, plainly. The API needs a credential everywhere; the broker
is still anonymous, so the dashboard's MQTT read path is open to anything on the
tailnet that can reach port 9001. The write path is not: the browser's MQTT
client implements no PUBLISH packet, and every write to mission/command goes
through POST …/dispatch here. The outer boundary is the tailnet
(mote_bringup/tailscale/policy.hujson), which is what keeps this port off the
public internet. Do not expose it to a network the robots are not already
trusted on.
Routes¶
GET /healthz liveness, contract, robot count
GET /v1/config what the browser needs to bootstrap
GET /v1/robots the roster, each row with its presence
GET /v1/robots/<robot_id> one row + its live state (an operator)
POST /v1/enroll allocate (or return) a robot id
POST /v1/robots/<robot_id>/dispatch authorize, audit, publish a command
GET /v1/audit[?limit=&robot_id=] what was dispatched, by whom
GET /v1/maps basemaps this server can serve
GET /v1/maps/<site>/<floor>/map.json resolution + origin + size
GET /v1/maps/<site>/<floor>/map.png the basemap image
GET /v1/maps/<site>/<floor>/zones.json the floor's zone *binding* (has coordinates)
GET /v1/zones every floor's zone *vocabulary* (no coordinates)
GET /v1/zones/<site>/<floor> one floor's, as a zone/v0 document
GET /v1/sites the registry: every floor + its canonical revision
GET /v1/sites/<site>/floors/<floor> every revision, validated, with provenance
POST …/revisions/<rev> upload a candidate revision (a robot)
GET …/revisions/<rev>/bundle.tar.gz pull a revision (a robot)
GET …/revisions/<rev>/map.json that revision's own transform + size
GET …/revisions/<rev>/map.png that revision's own image
GET …/revisions/<rev>/zones.json that revision's own zone binding
POST …/revisions/<rev>/promote make it canonical (an operator)
POST /v1/sites/<site>/floors/<floor>/zones edited zones of a revision ->
a new candidate (an operator)
GET / the operator UI (static files)
POST /v1/enroll is specified in
control-plane.md and unchanged by
M3; the rest are below.
GET /v1/robots¶
Every enrolled robot, each row carrying the presence payload the server
last saw on that robot's retained topic.
{"schema":1,"broker_connected":true,"robots":[
{"robot_id":"mote-01","name":"Scout","site":"home","fingerprint":"serial:aaa",
"facts":{},"enrolled_at":"2026-07-26T18:41:02Z","last_enrolled_at":"2026-07-26T18:41:02Z",
"presence":{"schema":1,"robot_id":"mote-01","online":true,
"stamp":"2026-09-02T09:14:03.221Z","version":"0.4.1"}},
{"robot_id":"mote-02","name":"Rover","site":"","fingerprint":"serial:bbb",
"facts":{},"enrolled_at":"2026-07-26T18:44:10Z","last_enrolled_at":"2026-07-26T18:44:10Z",
"presence":null}]}
Presence and nothing more: a client picking a robot to dispatch to asks one question — which of these is online — and it should not cost a request per robot. Everything else about one robot is on that robot's own route.
presence: null means the server has heard nothing, not that the robot is
offline. A robot that is off publishes online: false through its Last Will
and the payload is there, retained, saying so; null is the answer for a robot
that has never connected — or for a server that cannot hear, which is what
broker_connected distinguishes.
GET /v1/robots/<robot_id>¶
One robot's registry row and the retained state as this server last saw it.
{"schema":1,"robot_id":"mote-01","name":"Scout","site":"home",
"fingerprint":"serial:aaa","facts":{},"enrolled_at":"2026-07-26T18:41:02Z",
"last_enrolled_at":"2026-07-26T18:41:02Z","broker_connected":true,
"presence":{"schema":1,"robot_id":"mote-01","online":true,"stamp":"…"},
"health":{"schema":1,"robot_id":"mote-01","state":"ok","summary":"…","subsystems":[…]},
"pose":{"schema":1,"robot_id":"mote-01","frame_id":"map","x":1.5,"y":-2.25,
"yaw":0.75,"site":"home","floor":"ground","stamp":"…"},
"capabilities":{"schema":1,"platform_id":"mote-01","capabilities":[…]},
"mission_status":{"schema":1,"id":"3e99cf44d1294ab5","platform_id":"mote-01",
"capability":"goto","state":"succeeded","terminal":true,
"source":"fleet","stamp":"…"}}
| Field | Source |
|---|---|
| the row | the registry: robot_id, name, site, fingerprint, facts, enrolled_at, last_enrolled_at |
broker_connected |
whether this server's subscription is live |
presence · health · pose · capabilities |
the retained payload on mote/v2/<id>/<leaf>, or null |
mission_status |
the retained payload on mote/v2/<id>/mission/status, or null |
| Status | Meaning |
|---|---|
200 |
the row, with whatever state the server holds |
401 |
missing, unknown, or revoked operator token |
404 |
no such robot in the registry |
This route exists so a client can discover and follow a mission without joining the broker. M3's split — reads over MQTT, writes over HTTP — is right for the dashboard, which wants a live stream. It is wrong for a client that asks once and acts: coupling that client to the broker makes it track the topic tree, retention semantics and a broker credential, which is three contracts where one would do. An HTTP-only client depends on this document alone.
The payloads are forwarded, not rebuilt. Each is the publisher's own
document — presence/health/pose per
control-plane.md, capabilities per capability/v0,
mission_status per mission/v0 — served with no field added, renamed or
reinterpreted, which is the rule the agent follows so that there is one
definition of these payloads and not a second one here. (They are re-serialised,
so key formatting is JSON's; the fields are the robot's.)
Absent state is null, per field. A robot the server has never heard from
answers 200 with the row and every state field null — not 404, which is
reserved for a robot that is not enrolled, and not an invented payload. What
null cannot say on its own is why, which is why broker_connected is
beside it: with the feed down, every field is null however healthy the fleet is.
mission_status is the last status, not a history. One transition is all
that is retained — a status is a snapshot rather than a delta, so the latest one
is the whole truth about that mission — and a client that wants every transition
subscribes to mission/status the way the dashboard does. Polling this route
follows a mission perfectly well and will miss intermediate states on a fast
mission, which is a property of asking rather than of listening.
Nothing here is persisted. Every topic it reads is retained, so a restarted fleet server is repopulated by the broker within about a second of connecting; a stored copy could only ever be the staler answer.
The operator token hides less than it looks. The gate takes it before the
robot is looked up, so an unauthenticated request gets 401 whatever id it
names. The same payloads are on the broker, which is still anonymous.
POST /v1/robots/<robot_id>/dispatch¶
Send one mission to one robot.
| Field | Type | Notes |
|---|---|---|
schema |
int | 1 |
capability |
string | a key from that robot's advertised capability set |
input |
object | the capability's typed arguments; {} for one that takes none |
issued_by |
string, optional | free text for audit; defaults to ui:<operator name> |
id |
string, optional | supply your own correlation id — a re-POST of the same id is recognised by the robot rather than run twice |
lane |
string, optional | concurrency lane; defaults to default |
capability_version |
string, optional | pin the contract this input was built against |
deadline |
string, optional | RFC 3339 instant after which the mission is pointless |
{"schema":1,"robot_id":"mote-01","id":"3e99cf44d1294ab5","capability":"goto",
"input":{"target":"kitchen"},"lane":"default",
"issued_at":"2026-07-26T19:00:27.412Z","issued_by":"ui:michael","audit_id":7,
"status_topic":"mote/v2/mote-01/mission/status"}
202 Accepted means published, not accepted by the robot. The id is the
correlation id the agent will echo on every status; status_topic is where to
watch for them. Whether the robot takes the mission is answered on that topic
within milliseconds — accepted, or rejected with a typed failure. An API
that reported "accepted" here would be inventing an answer only the robot can
give.
| Status | Meaning |
|---|---|
202 |
published to mission/command and recorded |
400 |
missing capability, an input that is not an object, an over-long mission, or an invalid robot id |
401 |
missing, unknown, or revoked operator token |
404 |
no such robot in the registry |
503 |
the broker could not be reached; nothing was published |
The input is not validated here. The capability that declared the
input_schema runs on the robot, which validates against its schema and its
zones and answers with a typed failure — invalid_input naming the property,
unresolved_zone naming the reason. A copy of the schema in the fleet server
would be a second contract to keep in step, and it would refuse missions a newer
robot understands.
What a dispatcher should read first is the capability set. It is retained on
mote/v2/<robot_id>/capabilities, and it carries the keys, the input schemas
and — through the zone reference $ref — which inputs are places, so those
can be pre-checked against GET /v1/zones before anything is
sent. The dashboard's dispatch form is generated from exactly that document.
One in-flight mission per (robot, lane) is enforced by the robot's task
layer, not here. Two operators dispatching at once both get 202; the second
robot-side answer is rejected with failure.class: "busy", naming the mission
that holds the lane.
GET /v1/audit¶
{"schema":1,"audit":[
{"id":7,"stamp":"2026-07-26T19:00:27Z","actor":"michael","action":"dispatch",
"robot_id":"mote-01","command":"goto target='kitchen'","command_id":"3e99cf44d1294ab5",
"result":"published","detail":"","remote":"100.64.0.7"}]}
Newest first, limit defaults to 100 (max 1000), optional robot_id filter.
command is a readable description of the mission, for a human reading the log.
The machine-readable record of what was dispatched is command_id beside it,
which every status the robot publishes carries back.
result is one of published · error (the broker refused or was unreachable,
with detail) · rejected (the API refused, e.g. no such robot) ·
unauthorized (no usable token — the actor is anonymous).
Refused attempts are recorded too. The row is written before the publish and closed with the outcome after, so a command that reached the broker can never be missing from the log; a log with an extra "we tried" line is the failure mode to prefer.
GET /v1/config¶
Everything the dashboard cannot work out from its own URL.
{"schema":1,"contract":"mote/v2",
"topics":{"root":"mote/v2","presence":"presence","health":"health","pose":"pose",
"status":"mission/status","capabilities":"capabilities"},
"broker":{"ws_host":null,"ws_port":9001,"host":"fleet-box","port":1883},
"foxglove_url":"foxglove://open?ds=foxglove-websocket&ds.url=ws://{robot_id}:8765",
"maps":true}
broker.ws_host is null unless --broker-ws-host was given, and the page then
falls back to the host it was loaded from — so reaching the fleet box by MagicDNS,
by tailnet address or over localhost all work with no per-deployment build.
GET /v1/maps, /v1/maps/<site>/<floor>/map.json|map.png¶
The basemap a pose is meaningful on.
{"schema":1,"site":"office_world","floor":"ground","image":"map.png",
"resolution":0.05,"origin":[-10.935,-5.958,0.0],"negate":0,"mode":"trinary",
"occupied_thresh":0.65,"free_thresh":0.196,"width":500,"height":300,
"image_url":"/v1/maps/office_world/ground/map.png"}
width/height come from the PNG header, so a client can place a robot before
the image has decoded. The world→pixel transform is
fleet.md Q5:
404 for a site/floor with no published map; 400 for a name that is not a
plain directory label (which is also the whole path-traversal story).
These routes kept their shape across M4 and changed their source. The bytes
now come from the registry below rather than from whatever an operator last
rsynced onto the box, map.json gained revision, and /v1/maps gained
revision + candidates per floor. Adding fields bumps nothing; a client that
ignores them is unaffected. The reader is no longer hand-rolled either: it is
mote_bringup.bundle, the same ROS-free module the robot writes revisions with
(fleet.md Q4) — which is what makes "the server validates what the robot saved"
a shared definition rather than two that agree by convention.
A floor that was seeded by rsync before M4 still works: sites() walks the
bundle layout, not a table, so such a floor appears with no upload history and
serves normally. It cannot be promoted onto until its map/ directory is a
symlink into maps/<rev>/ — the API says so with a 409 rather than
overwriting the directory.
GET /v1/maps/<site>/<floor>/zones.json¶
The floor's bound places with their coordinates, in the same map frame as
the basemap, so the dashboard can draw them and an operator can see the goto
targets they are about to type. This is the zone binding: it is served
beside the basemap, to a client that already has the basemap, and it is not
what a dispatcher should be given — see the vocabulary
below.
{"schema":1,"site":"home","floor":"ground","frame_id":"map","zones":[
{"name":"the kitchen","x":1.0,"y":2.0,"yaw":0.0,"radius":1.5,
"note":"the good kettle is in the store room","navigable":true},
{"name":"ward","x":4.0,"y":1.0,"polygon":[[3,0],[5,0],[5,2],[3,2]]}]}
Read from the canonical revision's zones.yaml, falling back to the
floor-level file for a bundle seeded by rsync. 404 for a floor whose
revision binds nothing — an empty list would claim the floor names no places,
which is a different statement — and 404 for a floor with no published map, because a coordinate
with no map frame to be in is not an answer.
The zone vocabulary¶
The names, and nothing else, served so that the question a dispatcher most needs to ask — what places can I name? — has an answer in the API rather than out of band. The shape is zone/v0.
Not because a coordinate would be wrong. A zone is a coordinate in the floor's
frame — a fact about the building — and every robot on the floor holds the same
one. It is that a caller of this route has no basemap to draw a coordinate on,
and being handed a number it cannot place is worse than not being handed it. So
the division is in the prefix: everything under /v1/maps is served beside a
basemap and gated on there being one, everything under /v1/zones is gated on
nothing.
A caller that must never be handed a map can be given /v1/zones and only
/v1/zones.
The payload is built, never stripped. A floor's zones are one file, and this route is a view over it assembled from the fields a vocabulary may carry — never that file with the geometry keys filtered out. The difference is the leak a filter permits: a geometry key added later that nobody remembers to strip, arriving as a plausible-looking coordinate rather than as a crash. A map revision carries a copy of the whole file, because that is how a floor's places reach a robot that has never driven there; the names sit at floor level, which is why this route answers for a floor with no published map at all. A candidate produced by the zone editor carries both halves, and promotion is what lifts its vocabulary to the floor: uploading is not publishing, applied to names as well as to coordinates.
GET /v1/zones/<site>/<floor>¶
{"schema":1,"site":"home","floor":"ground","revision":4,"zones":[
{"name":"the kitchen","note":"the good kettle is in the store room",
"navigable":true},
{"name":"sluice","note":"","navigable":false}],
"problems":[]}
A zone is a place-name: a human name bound to geometry. The record carries only what a prior cannot guess — the mission layer's resolver already knows what a store room is, and what it cannot know is that this building's store room is where the stationery lives.
| field | |
|---|---|
name |
What the place is called, which is also what a dispatcher types. Printable text with no leading or trailing space; unique within a floor, not within a site — two floors may each have a reception. Matched exactly, then case-insensitively and whitespace-normalised. |
note |
Free text for where reality diverges from what the name implies. The other names a place answers to belong here: a resolver reads the sentence, and there is no alias list to keep in step by hand. |
navigable |
Whether it is a legal destination. Not vocabulary — it is the planner's contract — but it travels with the names because it is not a coordinate. |
revision |
Bumped every time a floor's zones are written, so a reader can tell which of two copies is the later one. |
problems |
Empty when the vocabulary is well-formed; see below. |
kind, display_name, aliases, parent and tags were part of this
document and are retired. A floor written before that still loads — its
description is read as the note it was, and its kind: keepout still means
navigable: false, which is what carries a barrier across the change rather
than turning it into somewhere to drive to — and none of them are written or
served.
There are no coordinates, no frame_id and no map reference, by
construction: the payload is built from the fields a vocabulary may carry
rather than filtered of the ones it may not, so a geometry key added to
zones.yaml later cannot leak into it. The tests assert this by walking the
whole payload for geometry-shaped keys rather than checking the ones they
happen to know about.
Unlike the routes under /v1/maps, this is not gated on a published map. A floor someone
has named but no robot has mapped still answers here — names are a fact about
the building and do not wait on a SLAM session. 404 only when the floor has
no zones.yaml at all.
GET /v1/zones¶
Every floor's vocabulary in one call, for a dispatcher bootstrapping a fleet:
{"schema":1,"vocabularies":[…]}, each element the document above. Floors with
no zones yet are omitted rather than listed empty.
problems¶
Reported, not enforced. Two things can be wrong with a vocabulary while the map around it is perfectly good, so the server says so and still serves it — a floor's basemap must not stop being served over two rooms called the same thing:
- an ambiguous query — two zones answering to one name. A resolver must not
pick between them, so the name is simply unusable until an operator fixes it.
The robot's own loader does refuse such a file, because it would otherwise
resolve
gotoby dictionary order. - a name nobody could have meant — a stray space at either end, which makes
two names look identical on screen and resolve differently.
Caféandstore roomare not problems: they are what the places are called, and refusing them would be refusing the building's own vocabulary.
A file that has no coherent reading at all — a legacy keepout marked
navigable: true, a navigable that is neither true nor false — is refused at
the parse instead, by the same shared validator (mote_bringup/bundle.py) that
save-map runs locally. Those are not ambiguities to report; they are
contradictions, and honouring the last one written would make the flag mean
whatever was typed most recently. A retired field is not in that set,
whatever it contains: refusing a floor over a field nothing reads would be the
wrong price.
The map registry (M4)¶
The fleet server is the source of truth for sites, floors and map revisions. The shape of it is one rule:
Uploading is not publishing. A revision that arrives is a candidate: it is validated, stored, recorded, and changes nothing. An operator promotes one, which flips the floor's
mapsymlink and publishes the retainedcurrenttopic every agent pulls from.
That is also the conflict answer. Two robots that map the same floor produce two candidates, both kept, neither merged — a map frame's origin is an accident of where SLAM started, so merging two frames would break every bound zone coordinate (fleet.md Q4). The loser is retained for audit.
A revision is an immutable directory, and distribution is a copy plus one
atomic flip — the model sites.py already used locally, unchanged. The wire
form is a flat gzipped tar of the revision's files with the floor's zones.yaml
packed in beside them, because zones are coordinates in that revision's frame
and must travel with it. Packing is deterministic, so a revision always packs to
the same bytes and the digest announced on the retained topic keeps matching
what the download route serves.
GET /v1/sites¶
{"schema":1,"sites":[{"site":"home","floor":"ground","canonical":"20260727T101500",
"candidates":["20260728T090412"],"revisions":["20260727T101500","20260728T090412"]}]}
GET /v1/sites/<site>/floors/<floor>¶
Every revision the floor holds, re-validated on read — a revision on disk can rot (a restore, a half-copied backup) and "promotable" is a claim about now.
{"schema":1,"site":"home","floor":"ground","canonical":"20260727T101500",
"revisions":[{"revision":"20260727T101500","canonical":true,"ok":true,
"errors":[],"warnings":[],"uploaded_at":"2026-07-27T10:21:44Z","robot_id":"mote-01",
"bytes":186349,"sha256":"sha256:6f1c…","zones":["kitchen","ward"],
"map":{"image":"map.png","resolution":0.05,"origin":[-2.9,-2.9,0.0],"width":438,"height":238},
"occupancy":{"total":104244,"free":0.899,"occupied":0.05,"unknown":0.051},
"url":"/v1/sites/home/floors/ground/revisions/20260727T101500/bundle.tar.gz"}]}
POST /v1/sites/<site>/floors/<floor>/revisions/<rev>?robot_id=<id>¶
Body: the packed revision (application/gzip), ≤64 MB. pixi run publish-map
is the robot-side caller.
| Status | Meaning |
|---|---|
201 |
stored as a candidate; the body says under which id |
400 |
not a readable bundle, a bad name, or no robot_id |
404 |
no such robot in this fleet |
413 |
larger than the ceiling |
422 |
a readable bundle that is not a usable map revision; errors says why |
{"schema":1,"site":"home","floor":"ground","revision":"20260728T090412",
"canonical":"20260727T101500","promoted":false,"warnings":[],
"url":"/v1/sites/home/floors/ground/revisions/20260728T090412/bundle.tar.gz"}
The stored id may not be the proposed one. Revision ids are per-second
timestamps, so two robots mapping one floor in the same second collide; the
second is stored as <rev>-2 rather than overwriting the first, and the
response says so. Re-uploading byte-identical content is a retry and mints
nothing.
Server-side validation is mote_bringup.bundle — the same module the
robot refused to save an incomplete revision with, run again because an upload
can truncate where a local save could not. It checks: every required file
present and non-empty; map.yaml parses with a positive resolution, a finite
origin, an image that is a plain file name, and thresholds the right way round;
the image is a PNG whose dimensions are sane and match map_raw.png if that is
present (they are the same frame); the posegraph is there, or mapping can never
be continued in this frame; meta.yaml provenance; and the occupancy is not
degenerate — a revision can have every file in place and still be a uniform
grey rectangle, which is what a mapping run that never got going looks like.
Why this route has no credential. Everything it can do is inert: a candidate
changes no floor, is bounded in size and count, and is recorded in the audit log
against the robot that sent it. The write that does change something —
promote — is the operator's. Replacing the robot_id check with a per-robot
credential waits on robots having one to present.
POST /v1/sites/<site>/floors/<floor>/revisions/<rev>/promote¶
Operator token required. Body {"schema": 1}.
{"schema":1,"site":"home","floor":"ground","revision":"20260728T090412",
"url":"…/bundle.tar.gz","sha256":"sha256:1a2b…","bytes":186349,
"promoted_by":"michael","warnings":[],"announced":true,"detail":"",
"topic":"mote/v2/registry/site/home/floor/ground/current","audit_id":12}
| Status | Meaning |
|---|---|
200 |
the floor is on this revision — see announced |
401 |
no usable operator token (recorded as an anonymous attempt) |
404 |
no such revision |
409 |
the floor's map is a plain directory, not a published revision |
422 |
the revision is not promotable; errors says why |
announced is separate from success on purpose. The symlink flip is the
fact; the retained announcement is best effort. A broker that is down must not
leave a floor half-promoted, so the flip stands, the response says the fleet was
not told, and the server re-announces every floor at startup — which is what
repairs it.
GET …/revisions/<rev>/bundle.tar.gz¶
The packed revision, with the digest in X-Bundle-Sha256. An agent pulls this
after the retained announcement, checks the digest, stages the whole revision in
a temporary directory, renames it into maps/<rev>/, and flips the local map
symlink — so a half-transferred revision is never visible and nothing has to be
undone if the transfer dies.
GET …/revisions/<rev>/map.json, …/map.png, …/zones.json¶
The same three questions /v1/maps/<site>/<floor>/… answers, asked of a
revision that is not the floor's canonical one. Uploading is not publishing,
so an operator has a decision to make; these are what lets them see what they
are deciding about, and they are what the dashboard's review pane reads.
map.json is /v1/maps' payload with revision naming this revision and
image_url pointing at this route's map.png:
{"schema":1,"site":"home","floor":"ground","revision":"20260802T145731",
"resolution":0.05,"origin":[-2.927,-2.934,0.0],"width":438,"height":238,
"image":"map.png",
"image_url":"/v1/sites/home/floors/ground/revisions/20260802T145731/map.png"}
That URL is load-bearing. /v1/maps/<site>/<floor>/map.png serves whatever is
published, so a client that took the transform from here and the pixels from
there would draw the map the operator already has under the candidate's label —
which looks entirely convincing and is the exact failure this route removes.
zones.json carries one extra field over the canonical route:
{"schema":1,"site":"home","floor":"ground","revision":"20260802T145731",
"source":"floor","frame_id":"map","zones":[…]}
source is revision when the revision carries its own zones.yaml and
floor when it inherits the floor's. An operator reviewing a candidate is
entitled to know that what is drawn came from beside it rather than from inside
it, and the coordinates cannot say.
Unlike read_zones on the canonical route, this is not gated on there being a
published map — the review that matters most is the first candidate on a floor
with nothing published at all. It stays under a /v1/maps-shaped path and never
over /v1/zones, because it is served beside a basemap and that is what the two
prefixes divide.
All three are reads, and like every other /v1 route they need an operator
token.
POST /v1/sites/<site>/floors/<floor>/zones¶
Operator token required. The edited zone set, and the revision it was edited against:
{"schema": 1, "revision": "20260802T145731",
"zones": {"the kitchen": {"x": 1.0, "y": -3.0, "yaw": 0.0,
"note": "the good kettle is in the store room",
"polygon": [[0.0,-4.0],[2.0,-4.0],[2.0,-2.0],[0.0,-2.0]]}}}
zones is the zones.yaml shape — keyed by name, both halves of zone/v0 in one
entry — and it replaces that revision's set rather than patching it. An entry
may echo its key as name, which is dropped: the file keys by name and carries
no second copy.
{"schema":1,"site":"home","floor":"ground","revision":"20260812T211029",
"derived_from":"20260802T145731","promoted":false,"warnings":[],"audit_id":31}
| Status | Meaning |
|---|---|
201 |
stored as a candidate — revision is the new one, derived_from the edited one |
400 |
zones is not a mapping, or a name is not a directory name |
401 |
no usable operator token (recorded as an anonymous attempt) |
404 |
no such floor, or no such revision on it |
409 |
no revision given and the floor has nothing published to edit |
422 |
the edited set is not readable as a zones.yaml; errors says why |
Editing is a derivation, not a mutation. The named revision's map bytes are
re-packed with the submitted zones in place of its own and accepted as an
ordinary candidate — validated by the same code as a robot's upload, listed by
the floor route, promotable through the route above. Nothing already stored is
written: a promoted revision's bytes back a digest the fleet has been told, and a
candidate is immutable for the same reason an id is never reused. The response
carries promoted: false because that stays a separate, audited decision.
revision is what makes an unpromoted map editable, and that is the point
rather than a convenience. A fresh build arrives carrying zone_01..zone_07
from segment-map; without it the only thing an edit could derive from was the
canonical revision, so renaming those placeholders meant promoting them first —
publishing a map because it was wrong. It also keeps the coordinates in the
frame they were drawn in: the operator is looking at that revision's own map.
Omitted, the canonical revision is edited, which is the same thing for a floor
whose published map is what is on screen.
An entry's source is carried, not re-invented. It says what made the
zone — save-zone for a pose a robot was driven to, segment-map for a room an
algorithm read off a map, editor for a click — and a submitted entry keeps
whatever it names, so a zone this edit did not touch keeps what it arrived with.
The dashboard's editor sends editor on geometry it placed or moved. Nothing
decides anything from the field: a zone is a coordinate in the floor's frame
however it got there, and what the field buys is an operator being able to see
which zones somebody drew. A value outside the three is dropped rather than
refused, for the same reason — it costs nothing to ignore and a 422 would cost
the whole save.
The bar is the source's, not the upload's. A revision with no posegraph is
one mapping cannot be continued from — an error for a robot's upload, where the
session can be re-run, and a warning on a stored revision, which navigates
perfectly and which promote will accept. A derivation is therefore validated
the way promote validates: holding an edit to a stricter bar than the revision
it derives from would put an edit zones button beside a promotable verdict
that could only ever fail.
Two things this route deliberately does not do. It does not slugify or otherwise
repair a name — a zone a dispatcher cannot type is stored with a warning, as
everywhere else in the vocabulary (problems are reported, not enforced; the
robot's loader is what refuses one). And it never publishes: the audit row
names the actor, the floor and the revision edited, and the floor's map
symlink is untouched.
What the browser is allowed to do¶
The dashboard holds an operator token for this API and no broker credential at
all that can publish. The read path connects to the broker's WebSocket
listener with a client that implements no PUBLISH packet
(ui/mqtt.mjs) — the split is enforced by
omission, not by intention. A subscribe-only broker credential would make it
structural on the broker's side too, and waits on the broker having credentials
at all.
The token is what the page is built out of. /v1/config is operator-only
like every other route, so there is no read-only mode to fall back to: the
dashboard has two states, signed in or asking to be. Pasting a token starts
everything without a reload, and a token that stops working puts the page back at
the gate rather than into a half-state showing stale rows.
Reviewing a candidate is all GETs. The review pane reads a revision's
map.json, map.png and zones.json; the two writes beside them are the
promote M4 already had and the zone edit above, both operator-authorized and
both audited. The edit is the only write in the fleet that produces a revision,
and it produces an inert one: a candidate nobody is running, on a floor that has
not moved.