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:
- Where the service directory lives: storage, and how a resolver serves, relays and holds routes.
- The registry authority, its copies and held adapters.
- Remote commands and the authority's snapshot.
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