
# Node API

> Every HTTP route a scafo node serves, with its authentication, request shape and answers.

Every node of a fleet serves the same API, because the registry is in the bucket and any node can answer any call. Failures are RFC 9457 problem documents with `content-type: application/problem+json`; see [/reference/errors](/reference/errors).

Every route but `/.well-known/scafo/health` requires a token: a node, operator or workload token, or — on the storage and routing surfaces — the ingress token.

## Authentication

### Node and operator tokens

One scheme covers both: a machine and an operator are the same caller to the verifier. The token is a compact JWS presented as `Authorization: Bearer <token>`.

| Part | Value |
| --- | --- |
| Header `alg` | `EdDSA` — anything else is refused outright |
| Header `kid` | The signer's id: a node id or an operator id (they share one keyspace) |
| Claim `sub` | Must equal `kid` |
| Claim `aud` | `scafo-node` |
| Claim `iat`, `exp` | Seconds; `exp - iat` must be in `(0, 60]` |
| Claim `jti` | base64url of 16 random bytes |

| Rule | Value |
| --- | --- |
| Maximum lifetime | 60 s |
| Clock skew tolerated | 30 s at both ends |
| Public key source | `_scafo/operators/<kid>.json`, else `_scafo/nodes/<kid>.json` |
| Key cache TTL | 60 s — the revocation bound |
| Replay | **Single use.** The `jti` is recorded on acceptance and refused thereafter |

::warning
Because tokens are single-use, one token cannot both deploy and then poll the deploy. Sign a fresh token per request.
::

Every rejection is `auth-invalid` (401), whatever the cause — a wrong signature, an expired token, a missing header and an unknown `kid` are indistinguishable to the caller.

Routes marked **operator token** additionally require `kid` to name an operator record, and re-read that record on every request, so a revoked or replaced key cannot administer durable authority from a cache. A node token on those routes is `auth-scope` (403). Routes marked **admin operator** require `role: "admin"`.

Operator tokens carry an application scope: `*`, exact names, or prefix globs such as `shop-*`. A call naming an application outside it is `auth-scope`, with a detail that never discloses which applications the key does reach. Reads narrow instead of refusing and set `scoped: true` in the answer.

### Workload OIDC tokens

Accepted on the deploy routes only, and only for an application whose trust policy names the token's `iss`. Presented in the same `Authorization: Bearer` header.

| Rule | Value |
| --- | --- |
| Algorithms | `RS256/384/512`, `PS256/384/512`, `ES256/384/512`, `EdDSA`, `Ed25519`. The `HS*` family is refused outright |
| Issuer selection | From the token's unsigned `iss`, matched against the application's trust policy **before anything is fetched** |
| JWKS cache | 300 s; a failed discovery is backed off 30 s; one request may take 5 s; at most 64 issuer entries |
| Clock skew | 30 s |

A token that does not hold up is `auth-invalid`. A token that does hold up but whose issuer or subject the policy does not admit is `trust-mismatch` (403) — the one rejection that is authorization rather than authentication, and the one a CI job can act on.

### Ingress token

An opaque bearer token, compared against the node's admitted token list with a timing-safe comparison. It guards `/v1/storage/**` and `/v1/ingress/routes*` and nothing else. A node mints one at boot unless [`ingress.token`](/config#lists-and-maps) names some — `--ingress-token` once per token, `SCAFO_INGRESS_TOKEN` comma-separated, or the config file — in which case all of them are admitted and the first is written to the state directory's ingress-token file.

Storage requests may also carry `X-Scafo-Storage-Version: 1`. An absent or empty header means version 1; any other value is `invalid-input`.

## Route table

| Method | Path | Auth |
| --- | --- | --- |
| `GET` | `/.well-known/scafo/health` | none |
| `GET` | `/v1/state` | node or operator token |
| `POST` | `/v1/applications/:name/deploys` | node, operator or workload token |
| `GET` | `/v1/deploys/:id` | node, operator or workload token, scoped to the deploy's application |
| `GET` | `/v1/operators` | operator token |
| `POST` | `/v1/operators` | operator token |
| `DELETE` | `/v1/operators/:id` | operator token |
| `GET` | `/v1/applications` | operator token |
| `GET` | `/v1/applications/:name` | operator token, application scope |
| `POST` | `/v1/applications/:name/block` | admin operator |
| `POST` | `/v1/applications/:name/unblock` | admin operator |
| `POST` | `/v1/applications/:name/labels` | admin operator |
| `POST` | `/v1/applications/:name/retire` | node or operator token, application scope |
| `POST` | `/v1/applications/:name/unretire` | node or operator token, application scope |
| `POST` | `/v1/applications/:name/selector` | node or operator token, application scope |
| `POST` | `/v1/applications/:name/replicas` | node or operator token, application scope |
| `POST` | `/v1/applications/:name/domains/:host` | operator token, application scope |
| `DELETE` | `/v1/applications/:name/domains/:host` | operator token, application scope |
| `DELETE` | `/v1/applications/:name` | node or operator token, application scope |
| `POST` | `/v1/fleet/freeze` | admin operator |
| `DELETE` | `/v1/fleet/freeze` | admin operator |
| `GET` | `/v1/fleet/freeze` | operator token |
| `PUT` | `/v1/storage/objects/**` | ingress token |
| `GET` | `/v1/storage/objects/**` | ingress token |
| `DELETE` | `/v1/storage/objects/**` | ingress token |
| `GET` | `/v1/storage/list?prefix=&recursive=` | ingress token |
| `GET` | `/v1/storage/stat?key=` | ingress token |
| `POST` | `/v1/storage/locks/**` | ingress token |
| `DELETE` | `/v1/storage/locks/**` | ingress token |
| `GET` | `/v1/ingress/routes` | ingress token |
| `GET` | `/v1/ingress/routes/:format` | ingress token |

The storage and routing routes exist only when the node has a storage service configured, which is always the case under `scafo sail`.

Every JSON control request body is bounded by `api.jsonBytes` (default 65 536). A `content-length` above it, or a stream that grows past it, is `resource-limit` (413) and the body is cancelled rather than aggregated.

## Health

### `GET /.well-known/scafo/health`

Public. No parameters.

```json
{ "ok": true, "version": "0.1.0", "node": "vps-a" }
```

An application's process answers its own health at `/.well-known/celld/health`, which is a different endpoint on a different port.

## Certificates

No route of the node decides which names get a certificate. The ingress decides from the routing document it already polls at [`/v1/ingress/routes`](#ingress-routing): the hosts that document names exactly are the hosts that may get a certificate, and no other. Caddy's `tls.permission.scafo` approves an on-demand certificate only for a name some fleet's current document names as an exact route host or an API host; Traefik, told a certificate resolver, obtains what the rendering asks for on each router — its exact host, or the `tls.domains` wildcard certificate that covers it.

Which hosts the routing document names, and so which may get a certificate:

| Host | Named when |
| --- | --- |
| A bare platform apex | `api.public` is on, as an API host |
| `<name>.<platform domain>` | That application is serving: a live node runs a ready process for it, which a retired or blocked application never has |
| A claimed custom domain | The application that claimed it is serving |
| `<env>.<base>` under a custom domain claimed **with its subdomains** | `<holder>-<env>` is serving |

::important
The `*.<base>` entry of a claim that covers its subdomains exists for **routing, not for issuance**: it answers an arbitrary subdomain from the base holder's process, and no such name is ever approved on its strength. Whoever sends the first request chooses the name, so a certificate per unknown name would let anyone exhaust the certificate authority's weekly issuance limit for that domain. Those names are served over HTTPS only where the ingress already holds a certificate covering `*.<base>`.
::

## State

### `GET /v1/state`

Node and admin-operator callers receive the whole document. A non-admin operator receives it filtered to the applications its key reaches, with `scoped: true` set.

`DaemonState` fields:

| Field | Type | Meaning |
| --- | --- | --- |
| `node` | `NodeRecord \| null` | This node's registry record; `null` until it has registered |
| `lease` | `CoordinatorLease \| null` | Holder of the coordinator lease, whoever it is; `null` when nobody holds one |
| `coordinator` | `CoordinatorView \| null` | Effective election result; an expired lease record produces `null` |
| `processes` | `ProcessStatus[]` | What the supervisor reports running on this node |
| `placements` | `PlacementView[]` | Placement views. A missing placement is pending, never implicit assignment |
| `reconcile` | `ReconcileStatus?` | Last pass of the reconcile loop; absent on a node that runs no loop |
| `scoped` | `true?` | Present when operator scope filtered this document, even if the scope grants every application |

`CoordinatorView` fields:

| Field | Type | Meaning |
| --- | --- | --- |
| `node` | string | Id of the node holding the lease |
| `fence` | number | Fencing token of that lease; it only ever increases |
| `self` | boolean | `true` when the node answering is the one coordinating |
| `unplaced` | array? | Unplaced applications from this coordinator's last pass; absent otherwise, and only the current coordinator reports them |

## Applications

Every route takes a `:name` path parameter and enforces operator application scope before any I/O. There is **no create route**: the first deploy creates the application, and every setter here refuses a missing one with `not-found`.

Two boundaries stand in front of them. The reading, admin and domain routes answer **operator tokens only**; a node token on one is `auth-scope`. The four setters and the delete route answer **operator and node tokens alike**, because a node forwards a deletion to the coordinator under its own token — an operator's scope is still applied to the name.

Every route but the listing, the domain routes and the delete answers `{ "application": ApplicationRecord }`.

### `GET /v1/applications`

```json
{ "applications": [ /* ApplicationRecord */ ], "scoped": true }
```

Applications are returned in name order. An admin sees full records. A non-admin operator sees only the applications its key reaches, with the fleet-owned `labels` field removed and `scoped: true` set; `blocked` remains visible.

### `GET /v1/applications/:name`

Operator token. Answers one record, with the same tenant redaction. An application the key cannot reach is `auth-scope`; one that does not exist is `not-found`.

### `POST /v1/applications/:name/block`, `POST /v1/applications/:name/unblock`

No body. Admin only — application scope is checked first, then the admin tier. Blocking is one CAS update; reconciliation stops serving the application on each node's next pass, its hosts leave ingress, it gets no certificates and it accepts no deploys. Nothing is deleted, and unblocking restores the prior desired state. Each emits one observability event naming the actor.

### `POST /v1/applications/:name/labels`

Admin only. Body:

```json
{ "labels": { "account": "acme", "plan": "pro" } }
```

`labels` must be a JSON object of string values; an empty object clears them. Labels are replaced wholesale and are never interpreted by scafo. A missing application is `not-found`.

### `POST /v1/applications/:name/retire`

No body. One CAS update; it does not require coordinator authority.

### `POST /v1/applications/:name/unretire`

No body. Retirement is reversible during its grace period.

### `POST /v1/applications/:name/selector`

Body (JSON, ≤ `api.jsonBytes`):

```json
{ "selector": { "region": "eu-west-1", "tier": "fast" } }
```

`"selector": null` clears the constraint. Omitting `selector` entirely is `invalid-input`. An unsatisfiable selector is valid and is reported by assignment, not rejected here.

### `POST /v1/applications/:name/replicas`

Body (JSON, ≤ `api.jsonBytes`):

```json
{ "replicas": "all" }
```

`replicas` must be given: `"all"`, or a whole number of at least 1. Omitting it, or sending `null`, is `invalid-input` — there is no default. The coordinator acts on it on its next assignment pass.

### `POST /v1/applications/:name/domains/:host[?wildcard=1]`, `DELETE /v1/applications/:name/domains/:host`

No body. **Operator token only** — a node token, and the workload token a deploy pipeline holds, are both `auth-scope`: a pipeline publishes an application, it does not decide what the fleet answers on.

`:host` is the host itself, lowercased and held to the claim rule: fully qualified, at most 253 characters, at least two labels of 1–63 characters from `[a-z0-9-]` not starting or ending with a hyphen, a last label that is not all digits, and no wildcard, port, scheme, path, trailing dot or underscore. Anything else is `invalid-input` naming the rule. A host equal to or under one of this node's platform domains is `invalid-input` too: every host there is rendered from an application's name.

`?wildcard=1` on the `POST` claims the single label below the host as well. Only `1` counts. It additionally requires a key whose scope reaches `<name>-*`, since the claim also decides what `<name>-<env>` answers on; a key that does not is `auth-scope`.

Both answer:

```json
{ "application": { /* ApplicationRecord */ }, "host": "shop.example", "platformHosts": ["shop.apps.example.com"] }
```

`platformHosts` is where the application answers under this installation's own domains, and is empty when it serves no platform domain.

| Refusal | Meaning |
| --- | --- |
| `400` `invalid-input` | The host is no host to claim, it is under a platform domain, or the application already holds 64 domains |
| `403` `auth-scope` | The key does not reach the application, or does not reach `<name>-*` for `?wildcard=1` |
| `404` `not-found` | No such application; or, on the `DELETE`, the host is not a domain of this application — the same words either way, so a release can never take another application's claim |
| `409` `application-blocked` | Claiming for a blocked application. Releasing stays open |
| `409` `reference-held` | The host, its wildcard-claimed parent, or a host one label under the base being claimed is held by another application. The detail names the holder only when the caller's scope reaches it, and says *another application of this installation* otherwise |

Re-claiming a host the same application already holds is idempotent, and a claim that already covers its subdomains is never downgraded by a later `POST` that leaves `wildcard` out. Writes go referent-first — the application is checked, the claim is created, then the application lists the host — so a crash in between leaves a claim nothing routes to, which re-running the call repairs.

### `DELETE /v1/applications/:name[?force=1]`

Only `force=1` and `force=true` count as forced; anything else reads as not forced. Forcing skips both the retirement and the grace requirement.

Deletion is **coordinator-gated**: S3 has no conditional delete, so the lease is the only thing keeping two deleters apart. Any node accepts the request:

- This node holds the lease → it does the work.
- Nobody holds it → this node offers itself once, then does the work or finds out who won.
- Another node holds it → the request is forwarded there once, under a fresh node token this node signs for itself, and the coordinator's answer is returned verbatim, problem document included.

| Answer | Meaning |
| --- | --- |
| `200` `{ "deleted": string[] }` | Store keys removed: the record, its trust policy, its deployments, its deploys, its placement, its domain claims and the whole `fleets/<name>/` prefix |
| `503` `draining` *No node is coordinating* | Nobody holds the lease and this node could not take it |
| `503` `draining` *Not the coordinator* | The request was already forwarded once and the lease moved again; retry against a settled lease |
| `409` `capability-missing` | This node holds no signing key and cannot reach the coordinator |

## Deploys

### `POST /v1/applications/:name/deploys`

The artifact is the **raw request body**, `content-type: application/octet-stream`, streamed to a file and bounded by `deploy.artifactBytes` in both compressed and expanded form.

Everything else travels in one header:

| Header | Value |
| --- | --- |
| `x-scafo-deploy` | Unpadded base64url of the canonical JSON metadata document, at most 8192 characters |

The encoding is checked strictly: the header must match `^[A-Za-z0-9_-]+$`, decode as valid UTF-8 JSON, and re-serialize to exactly the bytes given (stable key order, no padding). Anything else is `invalid-input`, *Malformed request*.

Metadata fields:

| Field | Type | Required | Default | Notes |
| --- | --- | --- | --- | --- |
| `replicas` | number ≥ 1 or `"all"` | no | — | Omitted, or `null`, preserves the application's existing setting |
| `nodeSelector` | object of string→string | no | — | Omitted, or `null`, preserves the application's existing constraint |
| `source` | object | no | — | `{ provider, repo, ref, commit }`, all four required non-empty strings when `source` is given |

Three names are **refused** outright, each `invalid-input`, *Malformed request*:

| Name | Why |
| --- | --- |
| `application` | It belongs in the request path |
| `oidc` | OIDC provenance is recorded from the verified workload token, never from request metadata |
| `source.oidcSub` | Same reason |

Anything else the document carries is **ignored**: the header is read for the fields above and nothing else. The document itself must still be a JSON object, and so must `source` when given; anything else is `invalid-input`.

The artifact's own runtime configuration is not read here at all. The node locates `wrangler.jsonc`, then `wrangler.json`, at the archive root, refuses an artifact carrying neither with `invalid-input`, and hands that path to Celld untouched — which is why there is no metadata field for a script name, for bundling, or for a version to record the deployment under. `scafo deploy --var` and `--no-bundle` edit the config client-side, before the directory is packed.

The caller is authenticated **before** the metadata header or the body is read.

| Answer | Meaning |
| --- | --- |
| `202` + `DeployStatus` + `Location: /v1/deploys/<id>` | Accepted |
| `400` `invalid-slug` | The `:name` path segment is not an application name |
| `401` `auth-invalid` | The token did not hold up, whatever the cause |
| `403` `auth-scope` | An operator key that does not reach this application |
| `403` `trust-mismatch` | A workload token verified, but the application's trust policy admits neither its issuer nor its subject |
| `400` `invalid-input` | Malformed or non-canonical metadata, a refused name, or a missing request body |
| `409` `deploys-frozen` | An admin has frozen this fleet's deploys. Refused **before anything is created**, so no application is brought into being by a deploy that is about to be refused |
| `409` `application-blocked` | An admin has stopped the application |
| `409` `version-skew` | Another answering node serves this application, or is crash-looping on it, on a different Celld version, outside a rolling pair upstream declared. Refused at intake, before a byte of the artifact is read; the detail names every node that has to stop and the rolling pairs this build allows |
| `409` `capability-missing` | This node has no deploy runner, or no file-backed artifact stager |
| `429` `deploy-overloaded` + `retry-after: 1` | The node's deploy queue or staged-byte capacity is full. `scafo deploy` waits and comes back unless `--no-wait` |
| `408` `deploy-timeout` | A receive, queue or task deadline elapsed |
| `413` `resource-limit` | The artifact exceeds `artifactBytes`, or the archive exceeds `archiveEntries` |
| `503` `store-unavailable` | A registry write met transient errors until the tries ran out |

The order matters: the freeze is read first, then the application is created — or, when it already exists, checked for a block and updated with any placement intent the metadata carried — and then the Celld versions are compared. Only after all three does the artifact body begin to be read.

Everything after the `202` — the artifact's configuration among it — is reported on the deploy's own record, not here.

### `GET /v1/deploys/:id`

Answers `DeployStatus`:

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | |
| `application` | string | |
| `state` | `accepted` \| `uploading` \| `deployed` \| `failed` | `deployed` and `failed` are terminal |
| `deployment` | string? | Deployment the deploy produced, by version; absent until it has been recorded |
| `code` | string? | The [error code](/reference/errors) that failed it. Present on every failed deploy: `publish-failed` when `celld deploy` ended badly, `internal` for a failure scafo has no name for, otherwise the code of the refusal itself |
| `detail` | string? | Why it failed, when it did |

A deploy id is `<name>.<16 hex>`, and every state a deploy enters is a registry record under that application. **Any node answers a poll**, so it may land wherever an ingress sends it, and a restart of the accepting node loses nothing. The application in the id is what the caller is authenticated against, exactly as the upload was.

An id that is no deploy id names no application, so the caller is verified against a `null` application first and the answer is `404` `not-found`. A record filed under an id whose application half it disagrees with is `404` too.

A deploy nobody is carrying out any more is judged **at read time**. A record that is not terminal, has not moved for a whole 90-second liveness window, and whose accepting node is not answering is returned as `failed` with `code: "deploy-timeout"` and a detail naming that node — without waiting for a sweep and without writing anything, because a poll is a read. It costs one extra read, and only in that one case. The coordinator's reaper records the same judgement, with the same code and the same detail, on its next pass; it also deletes terminal records after seven days.

## Operators

### `GET /v1/operators`

```json
{ "operators": [ /* TrustedOperator */ ], "scoped": true }
```

Keys are returned in id order. An admin sees every key. A non-admin operator sees only its one-step rotation lineage — itself, the key it supersedes, and any key that supersedes it — and the answer carries `scoped: true`.

### `POST /v1/operators`

Body (≤ `api.jsonBytes`):

```json
{
  "id": "op-9f3c",
  "publicKey": { "kty": "OKP", "crv": "Ed25519", "x": "..." },
  "role": "operator",
  "supersedes": "op-1a2b",
  "applications": ["shop", "shop-*"]
}
```

| Field | Required | Rules |
| --- | --- | --- |
| `id` | yes | Non-empty string; validated as an operator id |
| `publicKey` | yes | Must be `{ kty: "OKP", crv: "Ed25519", x: <non-empty string> }`. Only public material is persisted; an accidental `d` is never copied |
| `role` | no | `"admin"` or `"operator"`. Defaults to the least-privilege tier for a fresh key, and to the caller's tier for a replacement |
| `supersedes` | no | Id of the key this one replaces. **Must be the caller's own id** — a key that supersedes another is authorized to revoke it |
| `applications` | no | Array of entries — an exact application name, or a prefix glob such as `shop-*` — or `["*"]` alone. Omitted inherits the caller's own scope. An empty array, a `*` beside names, or a `*` anywhere but as one trailing character is `invalid-input` |

Authorization rules, each answered `auth-scope` (403):

- A `supersedes` naming anything but the caller's own id.
- Registering a tier the caller may not grant.
- Registering a scope reaching past the caller's own.
- Granting `*` when the caller is not an admin. An operator-tier key may replace itself with a wildcard key, but may not hand one out beside itself.

A fresh grant additionally requires an explicit scope. Replacements are idempotent, so an interrupted rotation can be repeated.

Answers `{ "operator": TrustedOperator }`.

### `DELETE /v1/operators/:id`

Answers `{ "removed": "<id>" }`.

| Refusal | Meaning |
| --- | --- |
| `400` `invalid-input` | An operator cannot revoke its own key. Rotate instead |
| `403` `auth-scope` | An operator-tier key may revoke only the key it was registered to replace |
| `409` `reference-held` | This is the last trusted operator key, or the last admin key |

## Fleet

Statements an admin makes about the installation itself rather than about one application.

### `POST /v1/fleet/freeze`

Admin only. Stops deploy intake for the whole fleet; every node refuses `POST /v1/applications/:name/deploys` with `deploys-frozen` (409) until it is lifted. **Nothing else changes** — serving, reconciliation, retirement and application deletion all carry on.

The body is optional. A freeze with nothing to say is a POST with no body at all; a body, when there is one, is a JSON object:

```json
{ "reason": "moving every node to a new Celld release" }
```

| Rule | Value |
| --- | --- |
| Body | Optional. Present, it must be a JSON object, bounded by `api.jsonBytes` |
| `reason` | Optional string. Trimmed; an empty one reads as none |
| `reason` length | At most 200 characters — it is printed inside every refusal |
| `reason` shape | One line: no newline, no control character |

Anything else is `invalid-input`. `by` is the caller the guard verified, never anything the body said.

Answers:

```json
{ "freeze": { "v": 1, "frozenAt": "…", "by": "op-9f3c", "reason": "…" }, "created": true }
```

`created` is `false` when deploys were already frozen, in which case the **first** freeze comes back unchanged: freezing twice is one freeze, and neither the stamp nor the reason moves.

### `DELETE /v1/fleet/freeze`

Admin only. No body. Answers `{ "lifted": boolean }` — `false` when there was no freeze to lift. Nodes take deploys again on the next intake.

### `GET /v1/fleet/freeze`

Readable by **any** operator of the fleet, whatever its tier: whoever is refused a deploy is owed the reason. Answers `{ "freeze": FreezeRecord }`, or `404` `not-found`, *Deploys are not frozen*, when this fleet holds no freeze record.

A non-admin key on the two writing routes is `auth-scope` (403), and the detail says freezing is a decision the fleet makes about itself rather than one taken inside an application.

::read-more{to="/reference/registry#freezerecord--_scafofreezejson"}
::

## Storage

Behind the ingress token. A key is everything after `/v1/storage/objects/`, separators included, and is spelled back to the caller exactly as it was given.

| Method | Path | Request | Success | Not found |
| --- | --- | --- | --- | --- |
| `PUT` | `/v1/storage/objects/<key>` | raw value bytes | `204` | — |
| `GET` | `/v1/storage/objects/<key>` | — | `200` + raw bytes, `content-type: application/octet-stream` | `404` |
| `DELETE` | `/v1/storage/objects/<key>` | — | `204`; removes the key **and everything beneath it** | `404` |
| `GET` | `/v1/storage/list?prefix=&recursive=true\|1` | — | `200` + JSON `string[]` | `404` |
| `GET` | `/v1/storage/stat?key=` | — | `200` + stat object | `404` |
| `POST` | `/v1/storage/locks/<name>` | — | `204` acquired | — |
| `DELETE` | `/v1/storage/locks/<name>` | — | `204`, always | — |

Values are read and written as **bytes**, never as text. `recursive` is truthy only for `true` and `1`; anything else lists the immediate children. An empty listing is a `404`, not an empty array: the caller's storage contract requires a missing prefix and an existing empty one to be distinguishable.

Lock acquisition is try-acquire and **never waits**: a held name answers `409` `cas-conflict`. Release is idempotent. Keys and lock names must be relative, printable ASCII, with no empty, `.` or `..` segment; anything else is `invalid-input`.

::read-more{to="/reference/ingress-protocol"}
::

## Ingress routing

### `GET /v1/ingress/routes[?prefer=<node>|self]`

Answers the generic `FleetRouting` document as of the last reconcile pass. `prefer` is read here exactly as it is on the format route below.

### `GET /v1/ingress/routes/:format[?prefer=<node>|self]`

Answers the routing in a format the ingress reads natively. Only `traefik` is registered. An unknown format is `404` `not-found`, and the detail names the formats this node renders.

`?prefer=<node-id>` puts that node's upstreams first on every route. `?prefer=self` means the node being asked, so a colocated ingress never has to know the node's id. A node that serves nothing is preferred to no effect. Omitting the parameter asks for the fleet's own order.

Both routes refuse with `503` `draining` until this node's first reconcile pass has completed — an empty document would read as every route in the fleet withdrawn, where a refusal is a pause the ingress rides out on the last document that parsed.

::read-more{to="/reference/ingress-protocol"}
::
