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 --profile takes
  • summary — the one-line description stado scratch profiles prints
  • mechanism — how the target is made; local-account is the shipped mechanism
  • platforms — the release_platform values the profile may be leased on
  • shell — the login shell the account is created with
  • default_ttl — the lease length when --ttl is omitted
  • max_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.

  1. TTL. create writes expires_at from the requested or declared lease length, and the profile's max_ttl is the ceiling a request may not exceed.
  2. create reaps first. Every create destroys that host's expired leases before it makes a new one, so the ordinary path pays for the previous run's leak.
  3. 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.
  4. 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-account
  • profile '<p>' is declared for platforms <a>, <b>; target '<t>' declares release_platform '<rp>'
  • profile '<p>' allows at most 8h; 12h was requested
  • scratch 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 it
  • scratch 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