REST surface: add the route that drifted, and a Features entry that points here

Chapter 15 was written on 2026-08-31 and was correct for all 14 routes that
existed then. GET /member-credentials shipped three days later (#111) and the
page did not follow it.

- ch.15: route count 14 -> 15, a table row for GET /member-credentials, and a
  per-route detail section (field meanings, and that blockedCount is the
  policy's own blocked set, not knownCount - allowedCount).
- ch.11: a short entry for the REST face — what it is for, the bind: knob, why
  it exists, the drain-on-read gotcha — linking to ch.15. Deliberately no route
  table: a second copy is the defect, not the errors it collects.

The "not an agent channel" point is stated accurately: REST does not skip the
authorization gate. 14 of 15 routes resolve the caller through the same
CallerResolver and Authz table MCP uses, and /healthz is open on purpose as a
liveness probe. The real reason is that identity comes from the connection, and
a member's own child process is a connection the daemon must reason about —
which was wrong before (#161).

fleetd #252. A test in the repo now enumerates FleetApp's registrations and
fails when they no longer match, naming this page as the one to update.
Dai Ha
2026-09-03 16:00:25 +07:00
parent bfddb43631
commit 3a57e56677
2 changed files with 56 additions and 1 deletions
+36
@@ -3124,6 +3124,42 @@ The first attempt at this shipped **inert** and every test passed: the matcher g
members on `sonnet` never matched and zero seats were charged. Every test in that change put the lead
on the same profile name as the target — the one shape the live config does not have.
## The REST face — the operator's way in when MCP is not there
**What.** The daemon serves 15 HTTP routes on the same port as the MCP mount. Every capability
behind an MCP tool is reachable there, so the fleet can be driven with plain `curl`. The full
reference — each route, the `Authz` role it needs, and its matching MCP tool — is
[REST API Reference](15-REST-API-Reference).
**On.** Always on. The listen address is the `bind:` block in `fleetd.yaml` (`host` and `port`);
this host uses `127.0.0.1:8765`. `GET /metrics` is the one route that can be absent — it is
registered only when metrics are configured.
**Why it exists.** MCP is the agent channel, but an operator needs a way in that does not depend on
an agent session being healthy. A lead whose MCP mount has dropped cannot call a single `fleet_*`
tool, and that is exactly the moment you most need to see what the fleet is doing. REST is that
door, and it is also what a dashboard or an acceptance-test script would use.
**The gotcha: `GET /sessions/{id}/replies` destroys what it returns.** It drains the inbox, so the
first read is the only read. Send it to a file. Piping it through `head`, or any script that exits
early, loses the payload for good — and this is the route you reach for when a ticket has timed out
and a member's real answer is sitting in that inbox. There is no second copy.
**It is not an agent channel, and members should not be pointed at it.** Not because it skips the
authorization gate — it does not. Every route except `/healthz` resolves the caller through the same
`CallerResolver` and the same `Authz` table MCP uses, so a member calling REST gets a member's
rights. The reason is narrower: identity is resolved from the connection, and a member's own child
process is a connection the daemon must reason about. That has been wrong before — a member's `curl`
child was resolved as the primary until it was fixed. One channel for agents is one place to get that
right. `CLAUDE.md` therefore says nothing about REST, deliberately, and should keep saying nothing.
**Why there is no route table on this page.** There is already one, on chapter 15, and a second copy
is the defect — not the errors it accumulates. Chapter 15 was written on 2026-08-31 and was accurate
for all 14 routes that existed then. `GET /member-credentials` shipped three days later and the page
did not follow it, which is how the drift starts. A test in the repo now enumerates the routes
`FleetApp` registers and fails when they no longer match its inventory, naming chapter 15 as the page
to update.
### A note for anyone briefing a worker to read this page
**A worker cannot see the current version of this file.** `wiki/` is a submodule, and the parent
+20 -1
@@ -1,6 +1,6 @@
# REST API Reference
`fleetd` exposes 14 HTTP routes, built once in `FleetApp.build()`
`fleetd` exposes 15 HTTP routes, built once in `FleetApp.build()`
(`fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java:126-160`). No other page collects them in
one place — each route is currently documented only where it happens to matter for some other
topic, spread across seven pages.
@@ -35,6 +35,7 @@ the `Authz.Action` each handler checks (`Authz.java:44-71`) — "open" means the
| GET | `/agents` | Raw herdr agent list (every agent herdr tracks) | any authenticated caller (`READ`) | none |
| GET | `/members` | The fleetd-owned worker roster, joined with live herdr status | any authenticated caller (`READ`) | `fleet_list` (its `members` section only — `fleet_list` also adds peer `leads` and capacity data this route does not have) |
| GET | `/profiles` | Configured worker profiles and the default one | any authenticated caller (`READ`) | `fleet_profiles` |
| GET | `/member-credentials` | The member credential policy: which names it knows, which it allows, and how many it blocks — names and counts only, never a value | any authenticated caller (`READ`) | none |
| POST | `/members` | Spawn a worker | primary only (`SPAWN`) | `fleet_spawn` |
| DELETE | `/members/{paneId}` | Tear a worker down by pane id | primary only (`STOP`) | `fleet_stop` |
| POST | `/sessions/{id}/message` | Deliver a turn to a session, or answer a worker's `fleet_ask` | primary or architect (`SEND`) | `fleet_send` |
@@ -197,6 +198,24 @@ always answers `204`, with no path that surfaces an unknown `paneId` back to the
`sessions.release` itself does anything for an id it does not recognise was not checked for this
page.
### `GET /member-credentials`
Handler: `memberCredentials`, `FleetApp.java:378-391`. No body. Answers `200` with the live
credential policy, read fresh on every call rather than from a snapshot:
| Field | Meaning |
|---|---|
| `present` | whether a `memberCredentials` policy is configured at all |
| `policy` | the policy mode, e.g. `allow-list`; empty string when none is set |
| `known` | the credential names the policy knows about |
| `allowed` | the names a member is allowed to inherit |
| `knownCount`, `allowedCount`, `blockedCount` | the three counts |
**It returns names and counts, never a value.** That is the whole point of the route: a probe can
check what a member would inherit without the check itself becoming a way to read secrets. Note
`blockedCount` is the policy's own blocked set, not `knownCount - allowedCount` — the two can
differ, and the field is the one to trust.
### `GET /healthz`
Handler: `healthz`, `FleetApp.java:229-266`. The only route with no `allow()` call at all — it