On this page
Channels
A Stado channel is a declared connection used by a product operation. The deployment selects its endpoints and connection method; Stado checks the operation through that connection. A host-control route, a service endpoint and a public download origin are separate choices.
The five boundaries
| Boundary | What crosses it | Source of truth | Commands |
|---|---|---|---|
| host channel | fixed programs and scripts over the target's declared transport | registry target plus host key | stado host exec, product-specific stado host commands |
| service directory | service name to endpoint and consumer relationship | registry service declarations | stado service directory show, stado service directory connect, stado service verify |
| object authorization | object URI plus action; bearer remains local to the caller | object_api.namespaces and their Skarbiec items |
`stado storage stat |
| workload secrets | one exact item field delivered to one service environment | Skarbiec grant plus managed-service declaration | stado service grant sync, stado service auth check, stado service secret sync |
| public object and release HTTP | requests and object bytes through the selected HTTPS origin | STADO_API_URL, otherwise api.url |
`stado storage stat |
These boundaries are separate. A host may answer while a service relationship is wrong. A service may listen while its bearer is stale. The object API may be live while its verifier cannot read namespace credentials. Health therefore means the final operation succeeds, not merely that a process or TCP port exists.
The public origin may use a directly reachable server, reverse proxy, tunnel or provider-managed edge. Release clients do not choose a network provider or derive that origin from a host's control route. Object-service recovery does not configure the public connection.
stado storage stat keeps an authoritative absent separate from
unavailable and unreachable. A gateway that cannot reach its origin reports
the failed operation, selected origin and upstream cause; HTTP 503 produces
unavailable, not evidence that an object is absent.
That upstream cause is not the whole answer on its own. A gateway running on an edge runtime receives one word — internal error — for a hostname that does not exist, a refused connection and a failed TLS handshake alike, so the public object and release routes attach originDiagnosis to the failure they return: dns_unresolved when the selected origin's hostname has no public A or AAAA record, dns_resolved when it does and the failure is therefore the connection or the handshake, and dns_unavailable when the resolver itself could not be asked, which is never read as a missing name. stado storage stat prints that body verbatim, so the operator and the release gate read the same sentence.
stado web status --json reports the selected web edge separately, including
edge-unconfigured and edge_error when a product selects an undeclared Stado
edge. Desktop shows that same report under Services → Web hosting.
Host channel
The host channel resolves a target from the canonical registry, checks its pinned host identity and runs a fixed operation through Stado: Host channel covers its host-side halves, vault bearers, unit retirement, a service's environment, placement handoff and ordered connection paths, and Host channel: inventories and repairs the host-side inventories and repairs it carries.
Service directory
The directory joins a producer, its endpoint, and declared consumers. stado service directory show <service> displays the resolved relationship. stado service directory connect <service> --consumer <consumer> establishes a declared connection. stado service verify <service> exercises reachability from the declared consumers instead of treating the producer's loopback listener as fleet reachability.
A directory declaration is not proof of a live route. Verification records which consumer reached which endpoint and why a refusal occurred.
The marker holds the address that host dials
Several products resolve a service from an owner-only file, ~/.stado/forwards/<service>.local, rather than from an environment variable: Skarbiec's credential bridge reads weles-admission.local this way. stado service directory publish writes those files, and what it must write depends on where the service runs.
- The host that SERVES the service gets the address it serves on, from
endpoints[<that host>]. - Every other host gets its OWN resolver adapter for that service, from
service_resolver.adapters[], because the serving host's loopback port means something else — or nothing — on their machine. - A service whose resolver declares one adapter per consumer is refused by name, listing the consumers: the marker's filename carries no consumer, so nothing elects one consumer's socket for the rest.
publish reports the source of every address it wrote (directory-endpoint or resolver-adapter), reports every marker no declaration accounts for as a fossil, and removes exactly those under --prune. stado host inventory <host> judges each marker against both declared sources and prints declared_source beside its verdict, and Stado Desktop's Hosts screen shows the same rows under Service addresses this host dials.
A marker that no publication rewrites keeps whatever last wrote it, and a consumer reading it dials the wrong service. Every marker is therefore judged against both declared sources, not against endpoints alone, so a correct address published by a resolver adapter is not called undeclared.
Object authorization
How the object API authorizes an action, how a publishing client declares its product, and why the object API is the host's own Stado process: Object authorization.
Workload grants and service authentication
stado service grant sync binds an existing owner-only token file on one host to an exact consumer and capability set. Skarbiec reads the bearer locally and stores only its digest:
stado service grant sync <service> \
--host <target> \
--consumer <consumer> \
--capability 'read:<item>#<field>' \
--token-file '$HOME/.stado/<consumer>-skarbiec-token' \
--json
Repeat --capability for each capability. A grant a service's consumers declare in the directory is printed with stado service grant show <service> and minted with stado service grant mint <service>, through the same path.
stado service auth check then sends that bearer from the host to a read-only loopback endpoint. With --repair, it synchronizes the named item field into the managed environment, restarts only the declared unit, and checks the endpoint again. --take-over-listener is a separate, explicit recovery for an unmanaged process occupying the declared port. Stado Desktop's operator console runs auth check without confirmation only when --repair is absent.
The agent's own credential broker
A queue agent resolves the secrets a job declares in secret_env through the broker agent.skarbiec.url names on its host. stado repair stado-control-plane --step agent-skarbiec --target <target> --apply sets that value to the skarbiec endpoint the service directory declares for that host, and refuses a host the directory gives no endpoint rather than pointing the agent at a guess. It writes nothing when the value already matches, and the receipt carries declared, previous and changed.
stado doctor fails agent-skarbiec when the agent's consumer cannot read through that URL, and the check's own allowance is two probe intervals because the read decrypts through one GnuPG listener that the object-authorization sweep is using at the same moment. A broker that answers slowly or stops answering mid-request is usually a wedged GnuPG daemon: skarbiec recover-daemons on that host is the repair, and the check reports unmeasured rather than a verdict while nothing answers.
The agent's broker URL and the directory's declared endpoint can disagree silently: a URL naming a port nothing binds shows up only as a claimed job dying with cannot resolve job … secret <ITEM>: error sending request for url (…/v1/items/read). stado doctor's agent-skarbiec check and the repair above compare the two, so the cause is named instead of surfacing as a build failure in another product.
Items in a host's vault
stado credentials item put --host <target> --role <role> --type <kind> stores the secret that plays one role, reading the payload from stdin so no credential field enters a local or remote argument vector. stado credentials item show --host <target> <item> is its read: kind, schema, revision, tags, updated_at, per field the name, byte length and SHA-256, narrowed with --field <name>, and the item's context descriptors (login_method, account_ref, provider, …) as context: <name>=<value>, a nested entry only as <name> (structured). The decryption and the hashing both happen on the host, so comparing a digest against a local copy's answers "does the host hold what this declaration references" without either side sending the value.
The read exists because writes can land in the wrong vault unnoticed. skarbiec set-json on a workstation writes that workstation's vault; the fleet reads the target's own live vault, and retag-vault-item, stado credentials get and skarbiec get do not show a target's payload. Without this read, a migration can put every field into a vault nothing on the fleet reads, and the only symptom is a consumer answering 401 to a bearer it was never given.
Which vault a machine resolves
A machine can hold several vault files, and exactly one of them answers its credential operations. secrets.skarbiec.vault_file declares which: stado config set secrets.skarbiec.vault_file <path> for the machine you are on, stado host config set <target> secrets.skarbiec.vault_file <path> for a managed host. SKARBIEC_VAULT_FILE still overrides it for one process, which is how a build is exercised before it is installed.
With nothing declared the machine discovers one, searching the paths Skarbiec's own vaults command searches, in its order: ~/.local/share/skarbiec/skarbiec.vault.json, ~/.stado/skarbiec.vault.json, ~/skarbiec.vault.json. That is an answer only while one candidate is present, or while the candidates carry different owners.
stado credentials vault show [--json] reports the resolution for the machine it runs on, and stado credentials vault list --host <target> reports it for a managed host, marking the resolved vault with *. Stado Desktop shows the same on the Hosts screen as "Credential vault this host resolves". The states:
| State | Meaning |
|---|---|
declared |
secrets.skarbiec.vault_file names a vault this machine holds |
discovered |
one candidate, and no declaration was needed |
ambiguous |
several candidates claim one owner: every owner write and authoritative read here is refused |
declared-absent |
the declaration names a file this machine does not hold |
none |
no candidate at all, so this machine cannot write credential items |
unreadable |
that host's installed release has no such field, so its resolution cannot be read from here |
Nothing is ever merged and no vault is ever created to resolve this: which items belong where is the operator's decision, and a second vault created quietly is the defect rather than the recovery.
Two vault files claiming one owner on one machine — the skarbiec CLI defaulting to one path and an older Stado naming the other — make every owner write there refused, which in turn stops the release verifier's grant from being extended and fails object-auth in stado doctor --deployment-preflight. The ambiguous state names that condition instead of leaving it to be inferred from the failures it causes.
The pre-check runner
stado runner status <target> --profile precheck reports the isolated CI runner as separate facts, each with its own refusal: the launchd or systemd unit, the service account, the pf or nft egress boundary, the listener process under this runner's root, the Brama route the runner publishes, the Kronika agent identity and its signing secret's ownership, and the listener's own last logged event.
| Field | Meaning |
|---|---|
brama_route.published |
the address in $runner_root/routes/brama.url, which is what the runner dials |
brama_route.declared |
the skarbiec-style declaration for that host: the directory endpoint when it serves Brama, its own resolver adapter when it does not |
brama_route.matches |
false exits non-zero after printing the rest of the report |
listener |
the last Listening for Jobs, Running job, error or termination line in the runner's newest diagnostic log |
runner listeners |
every Runner.Listener on the host with its owner and path, because one machine runs several |
stado runner restart <target> --profile precheck restarts the unit in place
through launchctl kickstart -k and requires a Listening for Jobs line
written after that restart. If none appears, it terminates listeners under
that runner's root and restarts once more, then reports the log's final event.
An older readiness line is not evidence of reconnection.
The published route, declared route and listener state remain separate facts. A listener under another runner root cannot establish this runner's health, and an unrelated log warning does not replace the measured operation result.
The host's runner
A host carries one runner. It registers with the union of every label the fleet's workflows ask for — stado, stado-precheck, stado-release, stado-publisher, stado-control-plane — in the Default runner group, so one registration answers every job and takes them one at a time. No workflow changes are needed: GitHub matches labels, a runner may hold many of them, and a runner runs one job at a time.
stado runner install <target> --profile precheck is the whole lifecycle. It records what it registered in <runner root>/.stado/registered-runner and re-registers when that record and the declared profile disagree — labels and group are fixed at config.sh time, so a changed declaration that nothing re-applies changes nothing on the host. status, restart and remove address the same runner.
Registering, relabelling and removing all go through one credential — the Skarbiec item the runner profile declares — and GitHub answers a registration token at two addresses that are not interchangeable. POST /orgs/<org>/actions/runners/registration-token needs the organization's self-hosted-runner permission, and that is what an organization-wide runner requires; POST /repos/<org>/<name>/actions/runners/registration-token needs admin on that one repository. A credential with repository admin and no organization permission is refused at the first and accepted at the second, so install --repository <name> and remove --repository <name> address this host's runner against that repository, with no runner group, because groups exist only at organization level. The scope is recorded in <runner root>/.stado/registered-runner beside the labels, both status scripts print it, and status reports it as runner_scope; a changed scope re-registers exactly like a changed label set. A refused token names the item, the permission the organization door needs, and the repository door the same credential can open, because a bare 403 reads as "runners cannot be managed from here".
Several runners on one host, each installed when a repository needed CI, coordinate nothing: GitHub hands a job to whichever runner is idle, and Stado's slot cap governs only its own queue. Parallel claims on one machine's disk and memory can exhaust it until no .NET listener on the host starts at all — Failed to create CoreCLR, HRESULT: 0x8007000C, exit 137, relaunching every few seconds. One runner per host bounds that by construction.
A runner that cannot start is usually a host that cannot allocate: Failed to create CoreCLR, HRESULT: 0x8007000C is E_OUTOFMEMORY, and it repeats with plenty of disk free. stado space report <target> carries a memory field with free kibibytes and the swap line; host exec allows vm_stat and sysctl vm.swapusage; and stado space reclaim has a runner_work_trees stage, because a runner clears its own _work after each job it finishes and nothing clears it after a cancelled job, a killed listener, or a runner whose repository stopped using it.
What bounds the machine is the host's job gate. GitHub gives each runner a concurrency of one and coordinates nothing between runners, so a host carrying five of them runs five builds at once. Each runner's ACTIONS_RUNNER_HOOK_JOB_STARTED therefore runs one program — the same text on both platforms — that waits until no other runner on the host holds a job marker, then writes its own; clean-work.sh removes it after the job's last step, so a marker spans exactly one job. A marker whose process no longer exists is a crash rather than a job and is cleared instead of blocking the host until someone notices. status and install report it as host_job_slot: none, <account> pid=<n>, or <account> stale.
All of it is on both surfaces. Fleet › Hosts › selected host › GitHub runner in Stado Desktop reads the runner, installs or reconciles it with the repository scope typed in, restarts it in place and removes it, and shows the registration scope, the labels and the host job slot beside the exact read-only command — the same four verbs and the same fields as the CLI.
Failure ownership
| Result | Meaning | Repair owner |
|---|---|---|
| target absent from registry | the host channel has no declared destination | registry declaration |
| host identity mismatch | transport reached a different machine | host enrollment |
| directory has no consumer relationship | route was never declared | service directory |
401 or 403 from final endpoint |
caller grant or policy rejected the operation | caller/workload grant |
503 object authorization unavailable |
object API verifier cannot read namespace credentials | object verifier reconciliation |
| endpoint answers but final state is unchanged | process health passed, product operation failed | owning product workflow |
Retries do not repair a declaration or grant. They are appropriate only after a retryable transport failure where the same declared operation remains valid.
When the stable bind is gone
Only the release agent publishes a release-controlled product's stable bind; why a namespace declared without its grant takes the stable binds down, the repair, the fallback and what prevents it recurring: When the stable bind is gone.
Source: this website