On this page
Scratch targets
stado scratch leases a disposable target on a host the fleet already manages,
hands the caller one run's worth of isolated storage, and destroys it when the
run ends. It exists for one reason: a test that installs a runner, restarts a
unit or deletes files has to reach a real machine, and the test plan's rule that
no test touches the operator's real registry, vault or hosts leaves nowhere for
that flow to run.
What a scratch target is, and what it is not
A scratch target is a throwaway local account on a registered host, created
through the same registry-authorized host channel stado host user create uses.
Its home directory, its Stado storage root and its lease record are the whole of
it.
It is not a virtual machine — nothing is booted and nothing is imaged, so a lease costs one account creation rather than a provider bill. It is not a mock — the account is a real login on a real host, and a command run inside it touches that machine's filesystem, its launchd or systemd domain and its network stack. It is not a second fleet — the host remains exactly one registry target; the lease adds no target row, no coordinator and no beacon.
The account is trusted by copying the parent login account's
authorized_keys into it. No key is generated, no key is uploaded, no
credential is minted and no port is opened: the operator's existing SSH identity
reaches the disposable account over the channel that already reached its parent,
so a scratch lease creates no new key material and no new internet surface.
The declaration
The profiles are declared in stado-rs/data/scratch-profiles.json, compiled into
the binary with include_str! and read by
stado-rs/src/deploy/scratch/declaration.rs. Each profile declares:
name— the value--profiletakessummary— the one-line descriptionstado scratch profilesprintsmechanism— how the target is made;local-accountis the shipped mechanismplatforms— therelease_platformvalues the profile may be leased onshell— the login shell the account is created withdefault_ttl— the lease length when--ttlis omittedmax_ttl— the ceiling a requested lease length may not exceed
The shipped profiles are macos-account, declared for darwin-arm64 and
darwin-amd64 with /bin/zsh, and linux-account, declared for linux-amd64
and linux-arm64 with /bin/bash. A third mechanism is another row in that
file, not another command.
Lease lengths are durations, never bare numbers: a duration is digits followed
by m for minutes or h for hours — 90m, 8h. The declaration states
default_ttl and max_ttl in that vocabulary, --ttl takes it, and every
report prints it back in the same spelling, so no reader has to know which unit
an integer meant.
The rest of the capability is stado-rs/src/deploy/scratch/mod.rs for the
operations over the host channel, stado-rs/src/deploy/scratch/lease.rs for the
lease record and its name rule,
stado-rs/src/deploy/scratch/remote/programs.rs for the fixed remote
programs, stado-rs/src/deploy/scratch/registry_out.rs for the
emitted registry document, stado-rs/src/deploy/scratch/ops/eligibility.rs for
which hosts may be leased on, and stado-rs/src/cli/scratch/mod.rs for the
command group.
Invocations
stado scratch profiles [--json]
Reads the declaration and nothing else — no host, no registry round trip.
{"declaration":"stado-rs/data/scratch-profiles.json","schema":"stado.scratch-profiles.v1","profiles":[{"name":"macos-account","summary":"...","mechanism":"local-account","platforms":["darwin-arm64","darwin-amd64"],"shell":"/bin/zsh","default_ttl":"1h","max_ttl":"8h"},{"name":"linux-account","summary":"...","mechanism":"local-account","platforms":["linux-amd64","linux-arm64"],"shell":"/bin/bash","default_ttl":"1h","max_ttl":"8h"}]}
stado scratch hosts [--json]
Which registry targets a lease may be taken on, and why the others may not. A caller that has to reach a real machine — an operator at a terminal, or a test — asks this instead of naming a host: the answer comes from the registry and the profile declaration together, and both are already the fleet's own words.
{"declaration":"stado-rs/data/scratch-profiles.json","hosts":[{"target":"charless-mac-mini","release_platform":"darwin-arm64","ssh":"charles@100.120.25.24","profile":"macos-account","eligible":true,"refusal":null},{"target":"gcp-zonal-dispatcher","release_platform":"linux-amd64","ssh":null,"profile":"linux-account","eligible":false,"refusal":"kind is 'gcp'; a lease is only taken on a local host"}],"eligible":1,"status":"read"}
A target is leasable when the registry declares it local with an ssh
destination and some profile declares the platform that target says it runs.
Every ineligible row carries the reason in its own sentence, because "no host
is leasable" and "this host is leasable and the command still failed" send an
operator to different places. This is a registry read: it runs no process and
therefore reports no exit_code.
stado scratch create --host <TARGET> --profile <NAME> [--name NAME] [--ttl DURATION] [--root DIR] [--json]
Leases one target. The name is generated when --name is omitted, --ttl falls
back to the profile's default_ttl, and --root overrides where the lease's
Stado storage tree is written.
{"name":"scratch-ab12cd","target":"charless-mac-mini","profile":"macos-account","mechanism":"local-account","username":"scratch-ab12cd","ssh":"scratch-ab12cd@100.120.25.24","created_at":"2026-09-07T20:15:00Z","expires_at":"2026-09-07T21:45:00Z","ttl":"90m","storage_root":"/Users/x/.stado/scratch/scratch-ab12cd","registry_path":"/Users/x/.stado/scratch/scratch-ab12cd/registry.json","release_trust":["8f1cb0d4e2a7"],"account":"created","verified_login":"scratch-ab12cd","home_path":"/Users/scratch-ab12cd","reaped":["scratch-old11"],"exit_code":0,"status":"leased"}
account says what the run did to the machine. verified_login is the user the
host answered as when Stado logged into the new account, so a lease is reported
only after the login it promises has actually happened. reaped lists the
expired leases this create destroyed on its way in.
The emitted registry document is validated against the registry-v2 contract before it is written, so a scratch registry that a Stado command would refuse never reaches a test.
release_trust is what the lease is allowed to install. create reads the
fleet's own release trust, writes it into the lease's registry document, and
reports what it wrote — the trusted key ids, or the one sentence that says why
there are none: the fleet declares no release trust, the fleet declares no release trust keys, or the fleet's release trust is unreadable: <error>. The
human report prints the same three answers, because "this lease trusts nothing"
and "nobody could read what the fleet trusts" send an operator to different
documents.
The emitted document carries a release_control block with the fleet's
schema_version, its generation and its trusted_keys, and an EMPTY
products map. Trust is what a lease needs; desired state is not. Nothing
reconciles a throwaway account — no rollout runs there, no unit is promoted,
and a products map copied out of the fleet would declare live binds and live
ports on an account that is about to be deleted.
That block is what makes a pipeline-signed release deliverable to a leased
target at all. Before it the emitted document declared no trust, so every
signed version refused with registry declares no release trust keys: the
lease was isolated from the operator's fleet and from the fleet's signing
identity in the same stroke, and the one thing a disposable target exists for
— proving a real delivery on a real machine — was the one thing it could not
carry.
stado scratch list --host <TARGET> [--json]
{"target":"charless-mac-mini","ssh":"charles@100.120.25.24","leases":[{"name":"scratch-ab12cd","account":"present","home_path":"/Users/scratch-ab12cd","username":"scratch-ab12cd","profile":"macos-account","created_at":"2026-09-07T20:15:00Z","expires_at":"2026-09-07T21:45:00Z","requested_by":"Lukaszs-MacBook-Pro/lukaszbartoszcze","expired":false,"seconds_remaining":3244}],"exit_code":0,"status":"read"}
account is read from the machine — present or absent — and not from the
record. A lease whose account someone removed by hand is visible as exactly
that, rather than as a lease that looks alive because its own record says so.
requested_by names the machine and the user that took the lease, which is the
question an operator asks about an account they did not create.
home_path is the account's home as the host's directory service reports it,
not a guess from the platform, so a confirmation dialog can name the directory
it is about to delete. A record nobody can parse still appears, carrying
unreadable and a null seconds_remaining: it is a leak, it is the row an
operator most needs to see, and the reaper takes it on the next pass.
stado scratch destroy <NAME> --host <TARGET> [--json]
{"name":"scratch-ab12cd","target":"charless-mac-mini","username":"scratch-ab12cd","account":"absent","home":"absent","record":"absent","destroyed_at":"2026-09-07T21:02:00Z","storage_root":"removed","exit_code":0,"status":"destroyed"}
account, home and record are read back from the host by a probe after the
deletion rather than taken from the delete command's own word, so all three
reading absent is the assertion that the account, its home directory and its
lease record are really gone. destroyed_at is when they went.
stado scratch reap --host <TARGET> [--apply] [--json]
Destroys every expired lease on the host. Without --apply it previews.
{"target":"charless-mac-mini","apply":true,"leases":[{"name":"scratch-old11","expires_at":"2026-09-07T18:00:00Z","expired":true,"action":"destroyed"}],"destroyed":1,"kept":2,"exit_code":0,"status":"reaped"}
Lifetime
A lease is bounded by time, not by whether anybody remembers to clean up.
- TTL.
createwritesexpires_atfrom the requested or declared lease length, and the profile'smax_ttlis the ceiling a request may not exceed. createreaps first. Everycreatedestroys that host's expired leases before it makes a new one, so the ordinary path pays for the previous run's leak.- The host agent's janitor tick reaps locally. The host reads its own lease records and destroys the expired ones with no operator command at all, so a leaked account outlives its TTL by one tick even when the machine that leased it never comes back.
reap --apply. The operator's explicit sweep, with the preview that shows what the sweep would do first.
The lease record lives on the host, at $HOME/.stado/scratch/<name>.json in the
parent login account's home. That placement is what makes reaping possible
from the host itself: the janitor needs no registry, no store and no inbound
connection to know which accounts it is allowed to destroy.
How a test consumes a lease
create emits storage_root and the registry_path inside it. A test points
the product's own backend-overriding variables at that root, and every Stado
command it then runs reads and writes the disposable registry instead of the
operator's:
lease=$(stado scratch create --host charless-mac-mini --profile macos-account --json)
root=$(printf '%s' "$lease" | jq -r .storage_root)
WC_STORAGE_BACKEND=local WC_LOCAL_STORAGE_PATH="$root" \
stado registry host add scratch-ab12cd --json
stado scratch destroy scratch-ab12cd --host charless-mac-mini --json
WC_LOCAL_STORAGE_PATH is the emitted storage_root, and the registry.json
the report names is the document those commands mutate. It is the same isolation
tests/fleet/main.rs already uses — a storage path the run owns — with the
difference that the target inside it is an account on a real machine, so an
assertion can read state a command left on a host.
The isolation reaches one place a reader would not think to look. A canonical
read from a local filesystem store neither records nor reads this machine's
last-known-good registry copy at ~/.stado/cache/registry-last-good.json
(stado-rs/src/targets/fetch.rs), so a run pointed at the emitted root leaves
the operator's fallback copy of the whole fleet exactly as it found it. That
scoping is why a lease is safe to take on a working machine: the emitted
document names one target, and a one-target document standing in for a fleet is
a recovery that recovers nothing. It cuts the other way too — a lease whose own
document is missing is not quietly served the operator's fleet instead, which
is the document a disposable account has the least business reading.
The test area
stado-rs/tests/scratch/main.rs is the capability's own area, and it obeys
three rules the whole area states in its header. The host comes from the fleet:
every story calls stado scratch hosts --json and takes a leasable target from
that answer, so no test names a machine and none reads one out of the
environment. Only what a story named is touched: a lease is created under a
generated name and destroyed by name, and the canonical registry is read and
never written. A run that cannot reach a host fails carrying the fleet's own
words rather than falling back to a tempdir and reporting success.
The stories are stado-rs/tests/scratch/lifecycle.rs — one lease created,
entered, driven with a real host command through the emitted registry, then
destroyed with the account, its home and its record all absent, and one
minute-long lease left to expire so the sweep previews it and then takes it —
and stado-rs/tests/scratch/refusals.rs, which holds each refusal sentence
above against the command that prints it. stado-rs/tests/scratch/fleet.rs
carries what they share, including the lock that keeps the two host-mutating
stories out of each other's way, because create reaps a host's expired leases
and would otherwise sweep the expiry story's lease before that story could.
Run it the way an operator runs the CLI, with the fleet's configuration in the environment:
STADO_API_URL=https://<coordinator> STADO_CONFIG=~/.config/stado/config.json \
cargo test --test scratch
The Desktop section has the same kind of area:
desktop/StadoDesktop/tests/scratch/ScratchStoreTests.swift drives the real
binary through the real store — the declaration reaching the lifetime field, a
lease taken, listed and destroyed through the store, and a refusal reaching the
screen in the command's own sentence.
Refusal sentences
scratch profile '<p>' is not declared in stado-rs/data/scratch-profiles.json; declared profiles: macos-account, linux-accountprofile '<p>' is declared for platforms <a>, <b>; target '<t>' declares release_platform '<rp>'profile '<p>' allows at most 8h; 12h was requestedscratch lease '<n>' already exists on '<t>' and expires at <ts>no scratch lease named '<n>' on '<t>'<root> already exists; refusing to write a scratch registry over itscratch names are lowercase [a-z0-9-] beginning with a letter; '<n>' is not
The last two are the ones worth reading twice. A scratch registry is never written over a directory that already exists, because a lease that adopts state it did not create is not isolation. And the name rule is what keeps a lease name usable as a local account name on both declared platforms.
Source: this website