On this page

Service directory

How does a workload find a service without knowing which host runs it? It asks the service directory, the routing block of the registry that maps logical service names to the one host currently serving each of them.

What it is

Workloads address stado://service/<name> — never a host, tailnet address, or service port. The directory resolves that name to the active placement host. service_directory.authority names the one target and Stado binary allowed to serve canonical snapshots and commit routing changes; every other resolver fetches that versioned snapshot over the authority target's registry-owned SSH transport and never treats its bootstrap registry copy as current routing.

service_directory.generation is the routing epoch. placement move delegates to the authority, updates the active host for every service in the placement group, and increments the epoch in the same compare-and-swapped registry commit that moves the service declarations. Resolution fails closed: while the transaction lock exists, resolution for that profile is refused; resolver caches reject generation rollback and stop accepting connections after max_stale_seconds without a successful authority refresh.

stado registry set service_directory.<field>, service declare, service retire and service remove advance the epoch through the same writer. A directory that is not an object, or whose generation is missing or not a whole number, is refused with the registry unchanged — registry.service_directory.generation is not an unsigned integer; the registry was not changed — rather than restarted at one, a number resolvers have already read.

Each service entry carries two address maps that mean different things:

Field Meaning
endpoints[<host>] The address that host calls to reach the service. This is what each host's resolver keeps in that host's forward marker, and the only map probing uses.
standby[<host>] The address that host would serve on after a move. Nothing is supposed to answer there yet.

The two are separate fields so no reader can take one for the other: endpoints is the address a host calls and nothing else, and a verifier never reports a standby host unreachable for not answering at its future address.

placement move turns the destination's standby address into its endpoint in the same committed document that moves the service, because once the destination is the active host that is the address it calls. A destination the directory names under neither map is refused before the source is fenced with directory service <name> names <host> neither as an endpoint nor as a standby, so after the move no host could reach it there; run stado placement standby <profile> --host <host> first.

Who declares it

Operators, through registry commits. placement move is the only path that changes which host is active, and it goes through the authority. service directory consumer add declares that a consumer may use a service; resolution applies that consumer capability policy.

connect --consumer CONSUMER uses the fleet resolver's consumer admission and placement fence even with --no-verify. An undeclared consumer is refused; a missing service is not_found; a malformed declaration is config; a placement move in progress is infra_down. With --json, refusals use the same structured error envelope as other Stado commands. Consumer-resolution refusals include details.route_state: undeclared for an unknown service, invalid_declaration for an invalid declaration, refused for denied consumer admission, or unavailable for a placement transaction. details.service and details.consumer identify the requested route; details.machine identifies the machine executing discovery, not the service's placement host. Clients use these schema values rather than classifying message wording. A declaration refusal is not evidence of a failed transport and must not be retried through a relay to bypass admission.

Caller-route declaration failures carry the same context: an unreadable resolver declaration, missing consumer adapter, missing caller endpoint, missing placement or port reports invalid_declaration. The original error code is retained: a missing adapter is not_found, while malformed routing is config. An ambiguous adapter choice remains a usage refusal; failed transport remains infra_down. Desktop retains these details in Complete operation receipt.

For a remote placement, the caller receives its declared resolver adapter or its own endpoints[HOST].url, never a guessed address made from the serving host and its port. Without either route, the refusal names the caller host and service and points to consumer add --target HOST. In Desktop, Hosts → Routes → Available operations… → Resolve a consumer's service route and diagnose its connection exposes the consumer, caller host and optional declaration-only read. A route computed for another host is not a probe from that host.

stado service directory doctor brama --consumer tama --json diagnoses the route from the machine running the command. Unlike a declaration-only connect, it requires consumer admission and an actual HTTP answer; it has no --no-verify or remote --target mode. It uses the same resolver, refusal fields and persisted connection observations as connect. A missing service names the requested entry in details.service; a refused consumer remains refused, rather than being diagnosed as missing infrastructure. An HTTP response proves reachability only, not application authorization or a model verdict; inspect status and use the consuming product's diagnostic for its authenticated operation. Desktop exposes the same check through Hosts → Routes → Available operations… → Diagnose this host's admitted service connection, executed on the selected API host.

When the consumer also needs a local address, pass --target HOST. That host hands out the loopback port its resolver adapter listens on (stado host local port, run there), and the command commits the consumer's capabilities and the host's resolver adapter in one registry generation and prints the address it recorded. A consumer the host already routes keeps its address, so declaring it again never moves a port under a running client; --reassign gives that existing adapter a port the host hands out now, and the consumer's adapters on other hosts stay as they are. Omitting --capability preserves an existing consumer's capabilities. The target must already declare a resolver; this command does not install one, and an unknown target or a target without a resolver is refused with the registry unchanged.

service directory consumer remove removes the consumer's authorization and its adapters on every target in the same commit. It leaves other consumers alone. Neither command issues, widens or revokes a Skarbiec grant.

The running resolver observes adapter changes through its ordinary registry refresh and exits so its declared supervisor can apply the new listeners. No manual restart is part of this operation. A successful declaration records desired state; verify the consumer's actual connection before reporting it ready.

For example, after selecting the declared service, consumer, resolver host and required capability:

stado service directory consumer add "$SERVICE" "$CONSUMER" \
  --target "$HOST" --capability "$CAPABILITY" --json
stado service directory connect "$SERVICE" --consumer "$CONSUMER" --json
stado service directory consumer remove "$SERVICE" "$CONSUMER" --json

Stado Desktop exposes the same mutations under Hosts → Routes → Available operations… as Declare or update a consumer and its route and Remove a consumer and all its resolver bindings. The review shows the exact command. The selected Stado API executes it and returns the full output and failure receipt; Desktop does not silently edit a local registry. The operator console asks for confirmation before any request it does not judge a read from the command's own help, such as consumer add, consumer remove and publish.

Who observes it

Every registered host runs a loopback-only Stado resolver that consumes the authority snapshot. stado service verify goes further: it probes each declared endpoint from the host that is told to call it, because probing from the serving host proves the process is alive and proves nothing about whether the fleet can reach it. Each entry gets one of three states, never two:

State Meaning
observed Something answered at the declared endpoint, from the consumer's own host. The declaration is true right now.
unreachable Nothing answered. The declaration is false.
unverified The probe could not run — host down, channel refused, remote stado too old. "I did not look" and "I looked and it is gone" send an operator to two different places.

The distinction exists because a declaration can stay structurally valid while it is false: a directory entry pointing at a closed laptop leaves a forward with no upstream, and a worker refuses to claim work it cannot upload diagnostics for, for as long as nobody looks — while config validate, registry validate, and doctor pass, because none of them is about reachability. Standby addresses appear in the sweep as their own unverified rows: visible, never failures.

Where it lives

The directory is the service_directory block of the canonical registry. How it is stored and served, read from the registry authority and its copies, and reached by remote commands:

Inspect channels without contacting the control plane

Channels, local services and routes of your own.

A service this host runs beside its placement

Channels, local services and routes of your own.

A route of your own while this host's resolver holds no listener

Channels, local services and routes of your own.

Commands

stado service directory show
stado service directory endpoint <service>
stado service directory connect <service>
stado service directory publish
stado service verify
stado placement move
stado placement standby
stado placement profile list|show|declare|remove

stado placement profile reads and writes the placement profiles themselves. list [--json] prints each profile's name, services and hosts; show NAME prints one exactly as the registry holds it. declare --file PATH|- [--json] adds a profile, or replaces the one of the same name, from its JSON (name, services, start_order, stop_order, state, hosts with each host's units and probes, and optionally routing and allow_unhealthy_source), and in the same registry commit links each listed service's directory route to it: the route names the profile and drops managed_service, which a placement-backed route derives from the profile. The directory generation advances once. A profile may name only the host it is placed on, for standby to add destinations to, and may declare no state when its services keep nothing durable. Refusals, each before anything is written: a profile that does not parse as the registry reads one; a service another profile holds (service <s> already moves with placement profile <p>; …); a service the directory does not declare (service <s> is not in the service directory; declare it with stado service declare before placing it in a profile); a redeclaration that drops a service the profile holds; and a profile a placement transaction holds. The registry's own validation then judges the whole document, as every push does. remove NAME [--json] takes the profile out and turns each of its services back into a fixed route naming the unit the profile declares on the route's active host, dropping its standby addresses; it is refused for an unknown profile and while a move holds it. Stado Desktop runs the same four verbs under Hosts → Routes → Available operations….

stado placement standby PROFILE --host HOST --reason WHY makes a registered host a place the profile may move to, as one idempotent pass: each service of the profile is resolved through the service catalog and classified by its program — a managed program under $HOME/.stado/bin is delivered through the same manifest-verified path stado release version converge uses, at the version the host or the placed host declares under managed_versions; a release-controlled tree under $HOME/.stado/services/<product>/current is declared for the host in the product's release control, and the host's own agent stages it — then every unit is asserted through service ensure from the catalog for the host's platform, and only when every unit is declared is the host written into the profile (with the placed host's probes) and into the directory as a standby endpoint. A pass that stops at a pending rollout reports awaiting_release_rollout; the next pass continues. Refusals name the cause: an unknown profile, the placed host standing by for itself, a service the catalog does not know, a program neither delivery path reaches, a product with no artifact for the host's platform (and the submit that would build one), a version nobody declares.

stado placement move moves a source whose units are stopped rather than refusing it. The transaction requires each source unit's file to exist — there is nothing to relocate otherwise — and carries a unit that is installed and not running, saying so in the move's output; fencing a stopped unit is a no-op and the state copy is unchanged. The source's health probes are skipped only when nothing of the profile is running there, because a probe against a dead service answers nothing about the move.

A destination that already holds its own file or tree at a state path keeps it. The move writes the source's state over that path behind a backup, and once the move commits the backup is renamed to <path>.replaced-by-placement-<transaction> beside the moved state instead of being deleted, because what the destination held there was never part of the source. The receipt lists each kept path under kept_replaced_state (kept what <host> held there before the move: <path> in text). A backup that could not be settled is listed under unsettled_state with its cause and stays under its .pre-stado-placement-<transaction> name; the move itself is committed either way. A path the destination did not hold leaves nothing behind, and a rollback before the commit restores the destination's own file in place as before.

A health probe ends on facts, never on a count of attempts or a clock. A running source's probe URL must answer 2xx once; any other answer refuses the move before the source is fenced, as <host>: <url> answered HTTP <status> or … answered no HTTP answer (curl exit <code>: <curl's error>). After the destination's units start, each probe URL is read again for as long as its unit's process runs (launchd state = running, systemd active) until it answers 2xx, and every answer that differs from the one before is printed as <host>: unit <unit> runs; <url> answered …; reading it again. A 4xx answer is the service refusing the request itself, so the move is rolled back at once with <host>: <url> answered HTTP <status> while unit <unit> runs; the probe path or the service refuses the request, so the move does not wait for it. Once the unit no longer runs the move is rolled back with <host>: unit <unit> is no longer running; <url> last answered …. Between reads the destination's socket table is asked which TCP ports the unit's process (launchd pid, systemd MainPID) listens on: lsof on Darwin, ss on Linux. A process that listens on ports none of which is the probe URL's is serving elsewhere, and the move is rolled back with <host>: unit <unit> listens on TCP [<ports>], not on port <port> of <url>, which answered …; the probe URL or the service's port is wrong. A process that listens on no port yet is still starting, and one listening on the URL's port that answers 5xx is the service saying it is not ready; both are read again, a new process id is printed as <host>: unit <unit> restarted: process … is now …, and any line the socket table's reader writes that is not a socket is printed as <host>: socket table of unit <unit>: <line>. The wait has no limit; it stands in the register of open waits, so stado waits names it for as long as it lasts. Releasing the placement claim goes through the registry's commit, which reapplies it only after a lost race and answers every other failure as it is.

Flag-by-flag detail is in cli.

Not to be confused with

  • The registry — the directory lives inside it, but the registry declares what should exist; the directory answers where it is reachable from here.
  • A service declaration — the unit a host must keep running. The directory routes to it; it does not manage it.
  • A beacon — the host's own report of its unit state, one of the facts reconciliation joins with the directory's endpoint sweep.

Source: this website