Skip to content

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 changefleetctl 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.

{"schema": 1, "capability": "goto", "input": {"target": "kitchen"},
 "issued_by": "night shift"}
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:

px = (wx - origin_x) / resolution
py = height - (wy - origin_y) / resolution      # image y is top-down

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 goto by 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é and store room are 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 map symlink and publishes the retained current topic 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.