saphan machine
The machine registry: admitted hosts, slots, and their executor identities
saphan machine
The machine registry: admitted hosts, slots, and their executor identities
Subcommands
| Subcommand | What it does |
|---|---|
saphan machine admit | Admit a machine to the CA (owner act, D3: one-time per machine) |
saphan machine csr | Device-side: mint (or reuse) this machine's P-256 key and build its CSR |
saphan machine deliver-branch | Deliver one named branch to a machine's admission mirror and shared clone |
saphan machine list | List admitted machines with their slots and seated executor identities |
saphan machine login | Conduct a seat's vendor login where the seat lives, then verify it — the operator never types a path |
saphan machine observe | Read what a machine is running now, from that machine |
saphan machine pin | Seed the fleet host-key pin store for an already-admitted machine (owner act) |
saphan machine probe | Run one v0 catalog probe against a declared toolchain and record the result (RS-3, never installs) |
saphan machine probe-confinement | Measure a machine's filesystem-confinement capability on its own host and record it (never installs, never accepts a typed value) |
saphan machine refresh-repo | Refresh one machine's admission mirror and its shared clone |
saphan machine renew | Reissue a cert, or patch the ssh transport, for an already-admitted machine (owner act) |
saphan machine retire | Withdraw an admitted machine from the fleet roster (owner act; NOT a custody act) |
saphan machine seat | Seat an executor identity on an admitted machine's slot (owner act; cast-surface write path) |
saphan machine set-binary | Register this machine's agent binary path for a backend (owner act; NOT a custody act) |
saphan machine set-max-sessions | Change how many concurrent runs one slot of an admitted machine may carry (owner act; NOT a custody act) |
saphan machine set-price | Admit a machine-wide USD/hour rate for an admitted machine (owner act; NOT a custody act) |
saphan machine set-repo-dir | Register the absolute bare admission-repo path on a machine, per repo (owner act; NOT a custody act) |
saphan machine set-slots | Change how many executor-identity slots an admitted machine carries (owner act; NOT a custody act) |
saphan machine set-worktrees-root | Register the absolute directory this machine keeps its stream worktrees in (owner act; NOT a custody act) |
saphan machine trust-stamp | Stamp (or --revoke) the vendor's own trust-dialog key for a seat, derived from an owner act |
saphan machine unpin | Retire the fleet host-key pin for one machine (owner act) — removes, adopts nothing |
saphan machine unseat | Free one slot by invalidating the seat row on it (owner act; NOT a custody act) |
saphan machine admit
Admit a machine to the CA (owner act, D3: one-time per machine)
Admits a machine into the signed machine registry (RS-0c, D3: machine admission to the CA is an owner act, once per machine — spawn-permission is automatic afterwards). Presents a P-256 short-lived certificate for the machine, either freshly issued from the device's own CSR through the intermediate's custody (--csr, plus --intermediate-key/--intermediate-cert) or already issued and handed in as a file (--cert) — exactly one of the two is required. The cert's validity window is checked BEFORE anything is written: an expired cert refuses admission by name (machine-cert-expired), never silently accepted. A machine already present in the registry with a LIVE row refuses re-admission by name (machine-already-admitted) — a machine is admitted once (D3). A RETIRED row does not refuse: retirement withdrew the host from the roster, it did not delete its record, and the way back is a RE-ADMISSION (order a-retired-machine-has-a-way-back, on the seat way-back precedent): a NEW row that supersedes the retired one and NAMES the act in the record — never an edit of the rows it succeeds, which stay in the append-only log. On success the machine row is root-signed into the identity store with suite=p256 (Version A) and a cert reference (a sha256 fingerprint) — the cert and key bytes themselves are never fields on the row (§1.6). FIX-2: for --transport ssh, the declared --ssh-key is probed with a real, non-interactive dial before anything is written, refusing by name (machine-ssh-key-unverified) unless it actually authenticates or --force-unverified annotates the row instead of refusing.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--admitted-at | string | unset | RFC3339 admission timestamp (default: now) |
--admitted-by | string | unset | the owner actor performing this admission (required) |
--binary | stringArray | [] | register this machine's agent binary for a backend as <backend>=<ABSOLUTE PATH ON THIS MACHINE> (repeatable, e.g. claude-code=/home/me/.local/bin/claude) — the path dispatch command derives --binary from; PATH resolution stays forbidden, so an unregistered pair refuses instead of guessing |
--cert | string | unset | an already-issued short-lived cert file (PEM or DER) — presented as-is |
--csr | string | unset | device CSR file (PEM or DER) — issues a fresh short-lived cert via the intermediate |
--device-key | string | unset | device private key write path for --mint-device-key (default: ~/.saphan/machine/device.key) |
--force-unverified | bool | false | write the machine row even if the ssh key fails a live probe (annotates the row as unverified; transport=ssh only) |
--gomodcache-root | string | unset | ABSOLUTE parent directory of this machine's own GOMODCACHE writable root, ON THIS MACHINE — the PARENT of the measured go env GOMODCACHE path; the measured path itself is refused (containment requires a strict ancestor, never an equal path) — the boundary a transport=ssh backend=codex spawn's resolved GOMODCACHE must fall under to compose; optional here, machine renew --gomodcache-root fills it later |
--host-ref | string | unset | host reference, e.g. vm-linux-1 (required) |
--intermediate-cert | string | unset | intermediate CA cert path (default: ~/.saphan/trust/intermediate.crt; only used with --csr) |
--intermediate-key | string | unset | intermediate private key path (default: ~/.saphan/trust/intermediate.key; only used with --csr) |
--machine | string | unset | machine id to admit, e.g. m1 (required) |
--max-sessions | int | 1 | concurrent-run capacity applied to EVERY slot this machine is admitted with (must be >= 1); machine set-max-sessions --slot patches one slot's capacity later |
--mint-device-key | bool | false | one-shot LOCAL admission: mint this machine's device key + CSR + issue its cert + admit, all in one command (transport=local only; cannot combine with --csr/--cert) |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) |
--slots | int | 1 | executor-identity slot count |
--ssh-host | string | unset | ssh HostName (required when --transport ssh) |
--ssh-key | string | unset | ssh IdentityFile path (default: autodetect ~/.ssh/id_ed25519, then ~/.ssh/id_rsa) |
--ssh-port | int | 22 | ssh Port |
--ssh-user | string | mmarzec | ssh User (default: current user) |
--transport | string | unset | transport adapter (local|ssh) — REQUIRED, unless --mint-device-key is given (it implies local): local to local means this engine never guesses a topology for a forgotten flag |
--ttl | duration | 24h0m0s | short-lived cert TTL when issuing via --csr |
--worktrees-root | string | unset | ABSOLUTE parent directory of this machine's stream worktrees, ON THIS MACHINE — the record dispatch command derives a transport=ssh --workdir from (<root>/wt-<stream>); optional here, machine set-worktrees-root fills it later |
saphan machine csr
Device-side: mint (or reuse) this machine's P-256 key and build its CSR
Runs ON the machine being admitted (RS-0d: "the device's own key is born
on the device", Version A — the service never custodies a device key).
Mints a fresh P-256 device keypair (trust.MintDeviceKeypair — the same
raw 32-byte-scalar format a future saphan-agent will persist, per the
order) at --out (default ~/.saphan/machine/device.key) and builds its
self-signed CSR with CN=--machine, written to --csr-out (default
<machine-id>.csr in the current directory). Carry the .csr file (a
PUBLIC request, safe to move) back to the owner for machine admit --csr
or machine renew --csr — the device.key itself never leaves this
machine. Refuses to overwrite an existing device key at --out (O_EXCL,
named class machine-device-key-exists) UNLESS --reuse-key is
given (renew-transport-patch): then the CSR is built from the key ALREADY at
--out instead — the key file itself is never written, read-only end to end
(same fingerprint before and after) — the real path to renewing a machine's
cert before expiry without orphaning the one already issued over that key.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--csr-out | string | unset | CSR write path (default: <machine-id>.csr in the current directory) |
--machine | string | unset | machine id this CSR's CN will carry — must match --machine at admission (required) |
--out | string | unset | device private key path (default: ~/.saphan/machine/device.key) — written on mint, only READ with --reuse-key |
--reuse-key | bool | false | build the CSR from the EXISTING device key at --out instead of minting a new one — the key file is never touched (renewal path; default mint behavior, and its refusal, is unchanged without this flag) |
saphan machine deliver-branch
Deliver one named branch to a machine's admission mirror and shared clone
Measures and delivers exactly --branch from the explicitly selected local repository to one machine. The far admission mirror is advanced first with one explicit branch refspec, then the far shared clone fetches that mirror. No force-push, prune, deletion, or push to the owner's origin is possible.
saphan dry-run machine deliver-branch performs the local, far-mirror, and far-clone measurements without changing either far repository and exits non-zero when either far ref is stale. The far mirror is the one registered for the PAIR (machine, --repo), with the machine's worktrees_root; they are never derived from the local checkout or ssh hostname. A machine that binds no repo pairs uses its unnamed repo_dir exactly as before. A machine that binds pairs but not this one REFUSES BY NAME — it never advances another repository's mirror.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--branch | string | unset | named local branch to deliver (required; never guessed) |
--repo | string | unset | ABSOLUTE path or workspace-relative name of the repository (required; never guessed) — names BOTH the local source and the (machine, repo) pair whose far mirror receives the branch |
saphan machine list
List admitted machines with their slots and seated executor identities
Read the signed machine registry (bridge/registry/MACHINES.md) and print one row per admitted machine (machine · host-ref · transport · suite · slots · admitted-by · admitted-at) plus the executor identities seated on its slots (D7: m1 carries fleet-1/fleet-2 at slots=2, m2/m3 one each, m0 transport=local). The registry is READ only here — admitting a machine is the owner act "machine admit" performs (RS-0c).
saphan machine login
Conduct a seat's vendor login where the seat lives, then verify it — the operator never types a path
Logs a SEAT in, at its own profile home, and verifies the result.
saphan machine login --machine <id> [--identity <id>] [--slot <n>]
That is the WHOLE input. The profile home is resolved from the machine registry through the one composer the engine also uses for probing and for spawning, so the operator cannot spell it differently than the fleet does.
WHY THAT MATTERS (measured 2026-08-26): the vendor keys its stored
credential on the config-dir path STRING. dir and dir/ are TWO
ACCOUNTS — a wrong spelling does not fail, it mints an identity and logs
into it, and the seat's real credential then expires with nobody
watching. One seat was out of the fleet for a day exactly that way.
WHAT IT DOES, in order:
- resolves the seat from --machine (never guessing when a machine carries several seats — it refuses and names them);
- names the credential entry it is about to write, and every SIBLING entry this seat already owns;
- conducts the vendor's own interactive login — in place for a local seat, over this fleet's own ssh transport WITH A TTY for a far one, so nobody has to ssh into the host by hand;
- reaps that session when the operator exits;
- VERIFIES with a real prompt. Never
-p /usage: on a dead credential that returns subtype=success with an empty stderr, and a check whose failure is indistinguishable from success is not a check.
--verify-only skips step 3 and only measures, which is also the negative control: point it at a seat whose credential is not usable and it refuses BY NAME instead of going quiet.
A vendor CLI that exits non-zero, or an operator who walks away, is a NAMED refusal (machine-login-abandoned) and the seat is left exactly as it was — this verb writes nothing to the registry, ever.
A duplicate credential entry is NAMED, never deleted: pruning a credential store is an owner act, so the command the owner would run is printed instead.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--identity | string | unset | which seat, when the machine carries more than one (refused as ambiguous rather than guessed) |
--machine | string | unset | machine id the seat lives on (required — and the ONLY thing that is) |
--slot | int | 0 | the seat's slot, when it is named explicitly; must agree with the identity's own seated slot |
--verify-only | bool | false | measure the seat's credential and report; conduct no login (the negative control) |
--verify-timeout | duration | 2m0s | wall clock for the verification probe |
saphan machine observe
Read what a machine is running now, from that machine
Options
| Option | Type | Default | Description |
|---|---|---|---|
--json | bool | false | machine-readable JSON |
--machine | string | unset | registered machine to measure |
saphan machine pin
Seed the fleet host-key pin store for an already-admitted machine (owner act)
Gives an ALREADY-admitted ssh machine a legal route into the fleet's own
host-key pin store (~/.saphan/ssh/known_hosts) — the ONLY other seeder,
machine admit, refuses re-admission by name and so can never reach a
machine admitted before this pin store existed. --machine pins exactly one
named machine; --all pins every transport=ssh machine currently in the
registry (exactly one of the two is required).
This verb copies a trust decision the OPERATOR already made — it reads the
operator's own ~/.ssh/known_hosts and moves a matching entry into the
fleet's store, the same adoption machine admit performs (one seeder,
hostkey.EnsurePins, two callers). Unlike admission, it NEVER invents a new
trust decision: it does not scan the network, and it does not accept a key
on first contact. --machine on a host with nothing to copy — no fleet pin
and no operator entry — refuses by name
(machine-pin-operator-entry-missing); the remediation is to ssh
into that host once yourself, so it lands in your own known_hosts, then
re-run this command. A host whose only candidate is an OpenSSH @revoked
entry in your own known_hosts refuses by name
(machine-pin-revoked) — a revocation is never adopted as a
pin. --machine on a machine that is not transport=ssh refuses by name
(machine-pin-not-ssh); the same machine with transport=ssh but
no ssh_host recorded refuses under its own class
(machine-pin-ssh-host-missing) instead — a different gap, a
different remediation (machine renew --ssh-host). An unknown
--machine refuses by name (machine-not-admitted, the same
class machine renew uses for the identical condition). --all never
hard-refuses on a single host's missing entry — it reports every host's
outcome (seeded / already-pinned / not-found / revoked / failed) so an
operator can tell "nothing to do" from "nothing happened", and exits non-zero
if any host failed.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--all | bool | false | pin every transport=ssh machine currently in the registry |
--machine | string | unset | machine id to pin (mutually exclusive with --all) |
saphan machine probe
Run one v0 catalog probe against a declared toolchain and record the result (RS-3, never installs)
Composes and runs ONE row of the closed v0 probe catalog (RS3_PROBE_SPEC_v0 §1.4) against --declared-path, parses its predicate over the real process output, and writes a signed capability row (rung, probe outcome, observed version, lease expiry) into the identity store — never a raw exit-0. A capability outside the closed catalog refuses by name. The catalog's Machines column records the legacy m0..m3 topology; it is not a runtime allow-list for either legacy or fleet machine IDs. The catalog's argv set is closed by construction, so this verb structurally cannot compose an installer call.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--capability | string | unset | capability name from the closed v0 catalog, e.g. gradle (required) |
--declared-path | string | unset | the manifest's declared absolute path for this capability's binary (required) — a claim, never evidence; the probe proves or refutes it |
--declared-version | string | unset | the manifest's declared version, if any — carried as provenance, never compared for ranking |
--machine | string | unset | machine id this probe fires on, e.g. m2 (required) |
--probed-by | string | m0 | the actor that FIRED this probe (never the subject machine) |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) — an automatic renewal signs with the same key material admission uses |
--workhome | string | unset | this machine's absolute work-home path (required; disk-headroom's <workhome>, and every probe's workdir) |
saphan machine probe-confinement
Measure a machine's filesystem-confinement capability on its own host and record it (never installs, never accepts a typed value)
Runs the confinement probe ON THE HOST THE ROW NAMES and WRITES the
answer to that machine's signed row. The result projects into machine list
and machine list --json.
The ARM is chosen by the row's transport, and there are exactly two:
transport=ssh the probe runs ON THE TARGET HOST, through that machine's OWN recorded ssh transport (ssh_user/ssh_host/ssh_port/ssh_key from the row — never localhost, never a hostname on the command line) transport=local the probe runs HERE, in this engine's own process. A transport=local row's runs are spawned by this engine on this host, so this host's confinement IS that row's confinement
Both arms run the SAME measurement and speak the same answer dialect; only the
transport differs. Which one took the measurement is recorded on the row as
probed-via, beside probed-by/probed-at — a row measured over ssh and a row
measured in-process are different observations and a reader must be able to tell
them apart.
The local arm has NO target parameter: it measures the machine this process runs
on and nothing else, which is why it cannot be aimed at the wrong host. It
refuses (confinement-probe-not-this-host) when the named row already
records a platform that is not the platform measured here — such a row names a
machine this engine is not running on.
WHAT is measured depends on the platform the far host reports for itself:
Linux the Landlock ABI, asked of the kernel (landlock_create_ruleset, VERSION flag)
Darwin seatbelt, PROVEN with two controls under a throwaway sandbox-exec profile
The row records one of five states:
unprobed never measured (the absent, fail-closed default) landlock abi=N measured capable (Linux) seatbelt measured capable (Darwin; the OS version rides in detail) unavailable reason=disabled the platform's facility exists and is not usable here unavailable reason=unsupported-by-platform no such facility exists on this platform
The two unavailable reasons print DIFFERENT next acts, because their remedies differ: one is an act on the host, the other has no remedy that exists and the honest next act is routing to a capable machine.
On Darwin, seatbelt is recorded ONLY when a DENY was proven. The probe
composes a profile whose single writable root is a throwaway directory it
created under that host's own temp dir, then runs BOTH controls: a write OUTSIDE
that root must FAIL and a write INSIDE it must SUCCEED. The presence of the
sandbox-exec binary is not the capability and is never recorded as one —
a tool that is installed and neutralised would otherwise read as a jail.
The ways the measurement can fail split on ONE question — was seatbelt measured at all:
no sandbox-exec · the profile refused · the deny not proven · the allow not
proven are recorded as unavailable reason=disabled with the failed
control named in the detail, and each prints its own next act,
executable on a Mac
no scratch dir RECORDS NOTHING and REFUSES
(confinement-probe-not-measurable): the probe could not
create its throwaway root, so it never composed a profile and
ran neither control. The next act is that host's TMPDIR
the profile RECORDS NOTHING and REFUSES, in the same class and for the
NEVER APPLIED same reason: sandbox_apply itself was refused, so
/usr/bin/true never ran under the profile and neither control
ran. The commonest cause is that the PROBE'S OWN SHELL is
already inside a seatbelt jail — which is evidence that the
host's seatbelt works, so recording an incapacity would be
backwards. The next act is to re-measure from an unconfined
shell
⚠ "the profile refused" and "the profile NEVER APPLIED" are ONE token apart and
land on opposite sides of that boundary. A row reading
unavailable reason=disabled with seatbelt=profile-rejected that was written
BEFORE this split may have been written by either cause — that build discarded
sandbox-exec's stderr, which is the only evidence that tells them apart — and
the row's own next act says so. Re-measuring replaces it.
A Darwin host that answers no seatbelt measurement at all likewise records NOTHING: "I could not measure" is not "I measured, and it is off" — and it is not "I measured, and it is on" either.
The throwaway directory is removed on every exit path. This verb detects; it never installs and never changes the far host.
The disabled act branches further, on the measured errno, because one
sentence for every errno is unexecutable for all but one of them: 95 EOPNOTSUPP is
Landlock compiled in and the LSM off (the kernel lsm= cmdline, an
owner act), 38 ENOSYS is a kernel that PREDATES Landlock (a kernel upgrade — there
is no cmdline token to add), and any other errno is NAMED without a cause, because
the probe measured that no version came back and never why.
The value is written by THIS PROBE ONLY. No flag, verb or config key on this surface accepts a hand-typed ABI, and none may be added. There is no exemption, override or --force path either (owner ruling D1) — a machine is confinable because it was measured, or it is not.
A probe that MEASURES and fails to RECORD exits non-zero and leaves the row
reading unprobed: the verb re-reads the row through the same loader
machine list uses, never its own copy of what it just wrote.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--machine | string | unset | machine id to probe (alias for the positional argument; must already be admitted, transport=ssh or transport=local) |
--probed-by | string | m0 | the actor that FIRED this probe (never the subject machine) — recorded as the row's probe provenance |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) |
saphan machine refresh-repo
Refresh one machine's admission mirror and its shared clone
Measures and refreshes exactly the currently checked-out base branch in the admission mirror registered for the PAIR (machine, --repo). It pushes only that base ref, never every head, never force, and never prune; the far shared clone then fetches origin so new worktrees actually fork from the refreshed base. A machine that binds no repo pairs uses its unnamed repo_dir exactly as before; one that binds pairs but not this repo REFUSES BY NAME rather than refresh another repository's mirror.
This is provisioning under the fleet's §1.7 ratification (2026-08-12): intra-fleet Mac↔runner transport is provisioning. It does not fetch, push, or otherwise mutate origin; origin remains exclusively the owner's hand. saphan dry-run machine refresh-repo measures without changing either far repository and exits non-zero when the mirror or shared clone is stale.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--repo | string | unset | ABSOLUTE path or workspace-relative name of the repository (required; never guessed) — names BOTH the local source and the (machine, repo) pair whose far mirror is refreshed |
saphan machine renew
Reissue a cert, or patch the ssh transport, for an already-admitted machine (owner act)
Renews an already-admitted machine WITHOUT re-admission and WITHOUT a new device key (RS-0e: a short-lived cert is a LEASE, not a re-enrollment — admission stays one-time per machine, D3). Requires the machine already present in the registry: an unknown machine refuses by name (machine-not-admitted) — renewal is never a back door into admission. Two shapes, chosen by which flags are given:
- CERT REISSUE (--csr or --cert): presents a fresh cert over the SAME
device key, either freshly issued from a device CSR through the
intermediate's custody (--csr, plus --intermediate-key/
--intermediate-cert) or already issued and handed in as a file
(--cert) — exactly one of the two, same shape as
machine admit. The CSR/cert's CommonName MUST equal --machine (machine-cert-subject-mismatch, the same FIX-2 C1 assertion admission makes); a CSR that cannot be read/parsed refuses by name (machine-invalid-csr); a cert already expired at presentation time refuses by name (machine-cert-expired). - TRANSPORT PATCH (renew-transport-patch, neither --csr nor --cert): ssh-only — patches --ssh-host/--ssh-user/--ssh-port/--ssh-key on the new row while INHERITING the current row's cert_ref verbatim — zero reissue, zero ceremony (the real path when the device key already has an outstanding cert and cannot itself produce a new CSR). Requires at least one --ssh-* flag; giving neither a cert/csr nor any --ssh-* flag is a named refusal (machine-renew-no-change) — a renew that changes nothing is never a silent no-op.
- --gomodcache-root: independent of the two shapes above (and of
transport) — declares the ABSOLUTE parent directory of this machine's
own GOMODCACHE writable root, the containment boundary a transport=ssh
backend=codex dispatch's resolved GOMODCACHE must fall under to
compose (asCodexGitRoots). This is the PARENT of the measured
go env GOMODCACHEpath — the measured path itself is refused, because containment (rootIsContainedUnder) requires a strict ancestor, never a path equal to the one it bounds. Given alone it still counts as a real renewal, not a no-op; a non-absolute value refuses by name (machine-gomodcache-root-invalid). Omitted, the current row's declared value carries forward unchanged.
On success a NEW machine row is root-signed into the registry (append-only:
the prior row stays in the log, latest-wins projects the new state for
machine list, which also surfaces which kind of renewal wrote it) —
host_ref/transport/slots carry forward from the current row unchanged either
way; a renewal changes the cert and/or the transport, never the machine's
identity. FIX-2's declared≠probed gate applies to BOTH shapes: for a
transport=ssh machine, the effective key (patched or inherited) is probed
with a real, non-interactive dial before anything is written, refusing by
name (machine-ssh-key-unverified) unless it authenticates or
--force-unverified annotates the row instead. The ssh-config projection
(~/.saphan/ssh/config) is regenerated on success, same as
machine admit.
saphan dry-run machine renew runs the SAME validation this real path runs — the registry-row lookup (an unknown machine still refuses by name), the renew-no-change guard, the --gomodcache-root absolute-ancestor rule, the ssh identity and live-probe gate, the CSR/cert subject and validity checks, the root-key load — and prints the row that WOULD be root-signed into the registry, then writes NOTHING: no registry row, no root signature, no ssh-config regeneration, no host-key pin. The one asymmetry: with --csr the fresh cert is NOT issued (issuance signs with the intermediate's custody, which a rehearsal may not touch), so the would-be cert_ref's sha256 is known only at the real act. An inadmissible act refuses with the IDENTICAL named refusal a real renewal would print.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--cert | string | unset | an already-issued short-lived cert file (PEM or DER) — presented as-is (cert-reissue shape; omit together with --csr for a transport-only patch) |
--csr | string | unset | device CSR file (PEM or DER), from the SAME device key — issues a fresh short-lived cert via the intermediate (cert-reissue shape; omit together with --cert for a transport-only patch) |
--force-unverified | bool | false | write the renewed row even if the ssh key fails a live probe (annotates the row as unverified; transport=ssh only) |
--gomodcache-root | string | unset | ABSOLUTE parent directory of this machine's own GOMODCACHE writable root, ON THIS MACHINE — the PARENT of the measured go env GOMODCACHE path; the measured path itself is refused (containment requires a strict ancestor, never an equal path) — default: inherit the current row's value; alone, with no --csr/--cert/--ssh-*, this is still a non-empty renewal |
--intermediate-cert | string | unset | intermediate CA cert path (default: ~/.saphan/trust/intermediate.crt; only used with --csr) |
--intermediate-key | string | unset | intermediate private key path (default: ~/.saphan/trust/intermediate.key; only used with --csr) |
--machine | string | unset | machine id to renew, e.g. m1 (required) |
--renewed-at | string | unset | RFC3339 renewal timestamp (default: now) |
--renewed-by | string | unset | the owner actor performing this renewal (required) |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) |
--ssh-host | string | unset | ssh HostName override (transport=ssh only; default: inherit the current row's value; alone, with no --csr/--cert, this is a transport-patch renewal) |
--ssh-key | string | unset | ssh IdentityFile override (transport=ssh only; default: inherit the current row's value; alone, with no --csr/--cert, this is a transport-patch renewal) |
--ssh-port | int | 0 | ssh Port override (transport=ssh only; default: inherit the current row's value; alone, with no --csr/--cert, this is a transport-patch renewal) |
--ssh-user | string | unset | ssh User override (transport=ssh only; default: inherit the current row's value; alone, with no --csr/--cert, this is a transport-patch renewal) |
--ttl | duration | 24h0m0s | short-lived cert TTL when issuing via --csr |
saphan machine retire
Withdraw an admitted machine from the fleet roster (owner act; NOT a custody act)
Marks an already-admitted machine as RETIRED: a new registry row carrying
every field of the current one forward VERBATIM plus retired_by/retired_at.
The row STAYS — retirement is a mark, never a delete (the identity log is
append-only, and a machine's history is not rewritten because it left the
roster). machine list keeps showing it, marked "retired", and
saphan run --machine <retired> refuses to cast onto it.
This is deliberately NOT a registry.ActRevoke row: the projection DROPS a
subject whose latest act is revoke (registry's eachLatestLive), so a revoked
machine would read back as never admitted — invisible in
machine list, and indistinguishable from a typo'd id at the next
retire. A column keeps the record readable and the refusals honest.
Requires the machine already present in the registry: an unknown machine
refuses by name (machine-not-admitted), and a machine already
retired refuses by name too (machine-already-retired) rather
than appending a second retirement row — an operator who typed the wrong id
must not read "retired" as confirmation about the machine they meant. This verb
is NOT an act of custody: it never touches a cert, a key, or the chain of trust
(machine renew still owns those), and it never revokes the
machine's credential — it withdraws the host from the OBSADA only.
Two refusals guard what the machine is still carrying:
- SEATED IDENTITIES (machine-retire-seated): the refusal
names them. Retiring under a seat leaves the seat row pointing at a machine
no dispatch may cast onto — it would look live and refuse only at the next
run. Move them first with
machine seatonto another machine; latest-wins-by-subject frees the old seat in the same act (each identity is seated on at most one (machine, slot) pair globally). - A LIVE RUN (machine-retire-live-run): a run_record row for this machine with status=running and an empty ts_ended. The refusal names the run_id. The question is asked of the RECORD, not of this Mac's process table — a transport=ssh machine's pid belongs to the far host, so a local liveness probe would answer a different question (RS-2b's own rule).
Options
| Option | Type | Default | Description |
|---|---|---|---|
--machine | string | unset | machine id to retire, e.g. macm5b (required; must already be admitted) |
--retired-by | string | unset | the owner actor performing this retirement (required) |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) |
saphan machine seat
Seat an executor identity on an admitted machine's slot (owner act; cast-surface write path)
Seats an executor identity (fleet-1, fleet-2, ...) on an already-admitted
machine's slot — the write half of the cast (a machine/slot with no seated
identity is a row nobody can cast onto; saphan run --machine reads
this row back). Requires the machine already present in the registry: an
unknown machine refuses by name (machine-not-admitted) —
seating is never a back door into admission. --slot must fall within the
machine's admitted slot count (machine.Slots): out of range refuses by name
(machine-seat-slot-out-of-range) — the SAME topology
arithmetic saphan run --machine's dispatch-time check (rule R8c)
enforces, moved to this write-time choke-point so a topology-inconsistent
seat is refused before it ever reaches the registry. A slot already seated
by a DIFFERENT identity refuses by name
(machine-seat-slot-occupied); re-seating the SAME identity
on its own current seat is a legal latest-wins no-op, never a collision. On
success the identity row is root-signed into the identity store, the same
signing discipline every other registry write verb follows (D-A3).
--backend (seat-backend-home, widened by
seat-backend-vocabulary-excludes-two-backends) names which credential this
seat carries, read from ONE closed vocabulary
(registry.SeatBackends, never retyped here): claude-code | codex | openai-compat | agent-loop | qwen-code.
The config-dir backends (claude-code | codex | qwen-code; claude-code
is the default) carry --config-dir, the profile home a saphan run --machine cast binds
this seat's LLM sessions to, on EITHER transport (order local-seats: a
local machine is a machine like any other — ssh exports it in the far
shell, a local cast mints it into the child's own environment); omitted, it
defaults to the $HOME/.claude-<identity> (or $HOME/.codex-<identity> for
--backend codex) convention on that machine — never a bare, unbound shell
shared across seats. The lane-bound backends (openai-compat | agent-loop)
carry --lane instead: a config-ladder alias this seat's credential
resolves from on ITS OWN execution host, never a profile home this engine
composes — see --lane below. A seat carries EITHER --config-dir OR --lane,
never both, never neither (owner's ruling, shape (a)): --lane given
alongside a config-dir backend refuses by name
(machine-seat-lane-on-config-dir-backend), --config-dir given
alongside a lane backend refuses by name
(machine-seat-config-dir-on-lane-backend), and an omitted
--lane on a lane backend refuses by name
(machine-seat-lane-required) — a lane backend has no
convention to fall back to. --identity and --config-dir can both ride into
a remote shell command line at cast time (runner/ssh.go), so both are
checked against the cast-path-input-gate: only [A-Za-z0-9._/-]+ (optionally
prefixed with a literal $HOME/) is accepted — a shell metacharacter or a
".." traversal segment refuses by name
(machine-seat-cast-path-invalid), nothing written.
proc is NOT a member of this vocabulary: a substrate binary reads neither
CLAUDE_CONFIG_DIR nor CODEX_HOME and resolves no lane, so it has no profile
home to seat — naming it refuses by its OWN named reason
(machine-seat-backend-not-seatable), never the generic
unknown-value refusal a typo gets
(machine-seat-backend-unknown). The SAME identity is never
bound to two homes at once, and a later
saphan run --machine --backend that disagrees with this seat's
own backend refuses (seat-backend-mismatch) rather than silently landing
the wrong tool in the wrong home. A row seated before this field existed
carries no backend tag and reads as claude-code — the only backend a seat
ever behaved as.
--billing-class (seat-credential-helper-w1a, the economic guard) names
which billing relationship this seat's vendor account holds:
subscription | metered | local-energy. Billing class is part of an actor's identity, and
changing it is the OWNER'S act — this flag is the only place it is ever
set. An unknown --billing-class value refuses by name
(machine-seat-billing-class-unknown), nothing written.
Omitting the flag is legal and leaves the seat unclassified: it reads back
as unknown, NEVER as subscription and never permissively — a row seated
before this field existed, or seated without --billing-class, is
indistinguishable from an explicit --billing-class unknown, and a consumer
of this field must treat unknown as a refusal, not a guess.
machine-seat-symmetry: identity rows are latest-wins per (kind, subject), subject = the identity name, never the slot — so seating an identity that already holds a LIVE seat on a DIFFERENT machine would otherwise supersede that old row silently, emptying its slot with no refusal and no mention in the projection. This is refused by name (machine-seat-cross-machine-move) unless --move-from names the machine the identity is CURRENTLY seated on, which turns the move into a conscious act; naming any other machine refuses the same way. Re-seating the SAME identity on its own current (machine, slot), or moving it to a different slot on the SAME machine, is unaffected — only a live seat on a machine other than --machine triggers this refusal. On a --move-from seat, the success output (and --json report) name BOTH sides of the act: the slot this seat now occupies, and the (machine, slot, config-dir) the move just freed.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--added-at | string | unset | RFC3339 seating timestamp (default: now) |
--added-by | string | unset | the owner actor performing this seating (required) |
--backend | string | claude-code | which credential this seat binds to --config-dir or --lane: claude-code | codex | openai-compat | agent-loop | qwen-code (seat-backend-home; registry.SeatBackends' closed vocabulary, READ from it — never retyped here); a cast's --backend must match, or it refuses seat-backend-mismatch |
--billing-class | string | unset | which billing relationship this seat's vendor account holds: subscription | metered | local-energy (the economic guard; omit to leave the seat unclassified — it then reads back as unknown, never as a guessed default) |
--config-dir | string | unset | profile home for this identity's remote LLM sessions (default: $HOME/.claude-<identity>, or $HOME/.codex-<identity> for --backend codex, on the far host); refused on a lane-bound --backend |
--identity | string | unset | executor identity to seat, e.g. fleet-1 (required) |
--lane | string | unset | config-ladder lane alias this seat's credential resolves from, on ITS OWN execution host (required for a lane-bound --backend: openai-compat | agent-loop; refused on a config-dir --backend) |
--machine | string | unset | machine id to seat the identity on, e.g. m1 (required; must already be admitted) |
--move-from | string | unset | the machine this identity is CURRENTLY seated on (required only when the identity holds a live seat on a machine other than --machine; makes the cross-machine move a conscious act) |
--role | string | executor | the identity's role on this seat: master | executor | review | design | scribe | advisor (spec.Validate's closed vocabulary, READ from it — never retyped here); executor (the default) leaves the seat UNRESERVED — it takes every declared role including none; any OTHER role RESERVES the seat — a run declaring a different role is refused at placement (R25), and so is a run declaring NO role at all, never silently seated |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) |
--slot | int | 0 | slot index on --machine (must fall within the machine's admitted slot count) |
saphan machine set-binary
Register this machine's agent binary path for a backend (owner act; NOT a custody act)
Patches an already-admitted machine's per-backend binary-path registry —
which agent binary this machine carries for which backend, the record
dispatch command derives --binary from instead of a master
remembering it (PATH resolution stays forbidden). Requires the machine
already present in the registry: an unknown machine refuses by name
(machine-not-admitted) — this verb is never a back door
into admission. Semantics are MERGE, never replace: registering claude-code
does not silently un-register codex — every other backend's entry carries
forward from the current row unchanged. cert_ref, host_ref, transport, slots,
and every ssh param carry forward VERBATIM from the current row — this verb
is NOT an act of custody (it never reissues a cert, never re-admits, never
patches the transport) — machine renew still owns all three. The
actor performing this write resolves from ~/.saphan/config's identity.actor
(env SAPHAN_ACTOR between the flag and the file) — --actor is an optional
one-off override, never the primary source (config resolves the canonical
name). --binary keeps the SAME validation machine admit's field
always carried, through the SAME accessor machine seat reads
(registry.SeatableBackends, widened by
seat-backend-vocabulary-excludes-two-backends): claude-code | codex | openai-compat | agent-loop | qwen-code.
A non-absolute path refuses by name
(machine-set-binary-invalid-pair, PATH resolution is
forbidden), and an unknown backend key refuses by name (same class) — a
typo'd key would sit in the record looking registered while every real
dispatch of the pair refused.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--actor | string | unset | one-off override of the write's actor (default: env SAPHAN_ACTOR, then ~/.saphan/config's identity.actor — the canonical source) |
--binary | stringArray | [] | register this machine's agent binary for a backend as <backend>=<ABSOLUTE PATH ON THIS MACHINE> (repeatable; MERGES into the current row — every other backend's entry carries forward) |
--machine | string | unset | machine id to patch, e.g. m1 (required; must already be admitted) |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) |
saphan machine set-max-sessions
Change how many concurrent runs one slot of an admitted machine may carry (owner act; NOT a custody act)
Patches an already-admitted machine's PER-SLOT SESSION CAPACITY — how many concurrent runs R8c (runrecord.ReserveSlot) may reserve on ONE slot before refusing a further dispatch. Before this verb existed the number was implicit: exactly 1, the arity of the run_record_live_slot index key, with no field anywhere naming it.
Requires the machine already present in the registry: an unknown machine refuses by name (machine-not-admitted) — this verb is never a back door into admission. Every other field on the row — host_ref, transport, every ssh param, every registered binary, the worktrees root, the slot count, and the machine's original admission provenance — carries forward VERBATIM from the current row; this verb is NOT an act of custody and never touches any slot's capacity but the one named.
Deliberately carries NO live-run guard, unlike machine retire and
machine unseat: lowering a slot's capacity while runs are live on
it cannot strand anything the way shrinking --slots can. The live rows keep
the index keys they already hold and run to completion; the next reservation
simply enumerates a smaller key space. The number self-heals as runs
finalize — this is a measured structural difference from set-slots,
not an omission (order slot-max-sessions §3(b)).
Three refusals guard the write, each fail-closed and each naming the next act:
- --max-sessions below 1 (machine-max-sessions-invalid): a capacity below 1 would enumerate zero ordinals in R8c's reservation loop, refusing every dispatch with "0 live runs, max 0" — numbers nobody wrote.
- --slot outside [0, slots) (machine-max-sessions-slot-out-of-range):
the SAME topology arithmetic
machine seatand the dispatch-time R8c check both enforce — a slot that does not exist cannot carry a capacity. - --max-sessions equal to the slot's current capacity
(machine-max-sessions-no-change): the same discipline
machine set-slotsandmachine renewalready apply — a write that changes nothing is refused rather than appended as a row a later reader must prove is a no-op.
saphan dry-run machine set-max-sessions runs the SAME validation this real path runs — the required-flag checks, the --max-sessions >= 1 guard, the registry-row lookup (an unknown machine still refuses by name), the --slot range check, the no-change guard, the root-key load — and prints the capacity change that WOULD be root-signed into the registry, then writes NOTHING: no registry row, no root signature. An inadmissible act refuses with the IDENTICAL named refusal a real write would print — a positional machine id (the verb takes none) and a missing --added-by both refuse in rehearsal exactly as they refuse at the real act.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--added-by | string | unset | the owner actor performing this change (required) |
--machine | string | unset | machine id to patch, e.g. macm5 (required; must already be admitted) |
--max-sessions | int | 0 | concurrent-run capacity for this ONE slot (required; must be >= 1) |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) |
--slot | int | -1 | which slot to patch the capacity of, e.g. 0 (required; must be within [0, slots)) |
saphan machine set-price
Admit a machine-wide USD/hour rate for an admitted machine (owner act; NOT a custody act)
Patches an already-admitted machine's PriceUSDPerHour — the machine-wide
USD/hour rate dispatch.Resolve's THIRD ranking key (Q6: capability rung
FIRST, free-live SECOND, price THIRD) reads through
MachineCandidate.PriceUSDPerHour. Every machine defaults to price 0 at
admission (machine admit), including every row admitted before this
field existed — this verb is the OWNER'S OWN ACT of admitting a real number;
nothing invents one for you.
Requires the machine already present in the registry: an unknown machine refuses by name (machine-not-admitted) — this verb is never a back door into admission. Every other field on the row carries forward VERBATIM from the current row; this verb is NOT an act of custody and never touches anything but the price.
An admitted price of exactly 0 is a REAL, WRITTEN fact — not a placeholder
for "unset" — because absence and an admitted zero are defined as the same
fact here (Machine.PriceUSDPerHour's own doc comment): a machine that has
never had machine set-price run against it already reads as 0, the
same value this verb would write if the owner explicitly priced it free.
Two refusals guard the write, each fail-closed and each naming the next act:
- --price-usd-per-hour below 0 (machine-price-invalid): a rate below 0 is not a price a resolver could ever honestly rank against.
- --price-usd-per-hour equal to the machine's current rate
(machine-price-no-change): the same discipline
machine set-max-sessionsandmachine set-slotsalready apply — a write that changes nothing is refused rather than appended as a row a later reader must prove is a no-op.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--added-by | string | unset | the owner actor performing this change (required) |
--machine | string | unset | machine id to patch, e.g. macm5 (required; must already be admitted) |
--price-usd-per-hour | float64 | 0 | machine-wide USD/hour rate (required; must be >= 0) |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) |
saphan machine set-repo-dir
Register the absolute bare admission-repo path on a machine, per repo (owner act; NOT a custody act)
Patches an already-admitted machine with the ABSOLUTE path of its bare admission repo ON THAT MACHINE. The value is never interpreted as a path on this host and is never an origin URL. This is a routine provisioning fact, not a custody act: cert_ref and every other field carry forward unchanged.
--repo-dir must be absolute. No default is guessed from the ssh host; an unregistered repo remains honest absence for every downstream reader.
WITH --repo <repo>, the path is bound to the PAIR (machine, repo) instead of to
the machine alone — because that is what a far repo path belongs to. A
workspace holds several repositories; a machine that names only one cannot
serve a stream owned by another, and must never serve it with the wrong
mirror. Pass the workspace's own name for the repository, the same token its
orders carry in - repo: <repo> (an absolute path under the workspace
normalises to the same key).
WITHOUT --repo, behaviour is exactly what it has always been: the path is written to the machine's single unnamed repo_dir, and a row that carries only that keeps serving every dispatch it serves today, unchanged.
ONCE A MACHINE BINDS ANY PAIR, THE UNNAMED PATH STOPS BEING AN ANSWER: a dispatch whose stream's repo has no pair on that machine REFUSES BY NAME and prints this verb. That is deliberate — the repositories in this workspace name their trunk alike, so a wrong mirror forks the wrong files and composes green. Bind each repository the machine is to serve, explicitly.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--actor | string | unset | one-off override of the write's actor |
--machine | string | unset | machine id to patch (required; must already be admitted) |
--repo | string | unset | WHICH repo this path is the far mirror OF — the workspace's own name for it, as its orders spell it (optional; omitted keeps today's single unnamed repo_dir) |
--repo-dir | string | unset | ABSOLUTE path of the bare admission repo on that machine (required) |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) |
saphan machine set-slots
Change how many executor-identity slots an admitted machine carries (owner act; NOT a custody act)
Patches an already-admitted machine's SLOT COUNT — how many executor
identities it may carry at once, the number machine seat bounds a
--slot against and the R8c dispatch walk enforces at spawn time. Before this
verb existed the count could only be set at admission: machine admit
refuses a second admission of the same id
(machine-already-admitted) and machine renew
carries slots forward unchanged, so growing a host's capacity meant admitting
the SAME physical machine again under a second id — two registry rows for one
host, which is exactly the drift this verb closes.
Requires the machine already present in the registry: an unknown machine
refuses by name (machine-not-admitted) — this verb is never a
back door into admission. cert_ref, host_ref, transport, every ssh param, every
registered binary, the worktrees root and the machine's original admission
provenance carry forward VERBATIM from the current row — this verb is NOT an
act of custody (it never reissues a cert, never re-admits, never patches the
transport); machine renew still owns those.
Three refusals guard the number itself, each fail-closed and each naming the next act:
- --slots below 1 (machine-slots-invalid): slots=0 means a
machine that carries no seats at all, and that state is what
machine retirewrites — with the seat and live-run checks a silent slots=0 would step straight over. - --slots below the machine's OCCUPIED slots
(machine-slots-below-seated): the refusal NAMES every
seated identity that would fall outside the new count. Shrinking under a
seat frees nothing — the seat row survives,
machine seatwould refuse to write it (machine-seat-slot-out-of-range) and the next dispatch onto it refuses too, so the loss surfaces at cast time instead of here. - --slots equal to the current count (machine-slots-no-change):
the same discipline
machine renewapplies — a write that changes nothing is refused rather than appended as a row a later reader must prove is a no-op.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--added-by | string | unset | the owner actor performing this change (required) |
--machine | string | unset | machine id to patch, e.g. macm5 (required; must already be admitted) |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) |
--slots | int | 0 | how many executor-identity slots this machine carries (required; must be >= 1, and never below the machine's occupied slots) |
saphan machine set-worktrees-root
Register the absolute directory this machine keeps its stream worktrees in (owner act; NOT a custody act)
Patches an already-admitted machine's worktrees root — the ABSOLUTE
parent directory its stream worktrees live in ON THAT MACHINE, the record
dispatch command DERIVES a transport=ssh --workdir from
(<root>/wt-<stream>) instead of refusing and handing the master a pattern to
fill in by hand. Before this field existed the refusal composed an EXAMPLE
path from the ssh user; it looked executable, was guessed, and a master who
pasted it got a failure instead of a run.
Requires the machine already present in the registry: an unknown machine
refuses by name (machine-not-admitted) — this verb is never
a back door into admission. cert_ref, host_ref, transport, slots, every ssh
param and every registered binary carry forward VERBATIM from the current row
— this verb is NOT an act of custody (machine renew still owns
those). The actor performing this write resolves from ~/.saphan/config's
identity.actor (env SAPHAN_ACTOR between the flag and the file); --actor is an
optional one-off override.
--worktrees-root MUST be absolute (machine-worktrees-root-invalid): a relative root would derive a workdir resolving against whatever directory the ssh session happened to land in — a value that sits in the record looking registered while composing a command that runs in the wrong place.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--actor | string | unset | one-off override of the write's actor (default: env SAPHAN_ACTOR, then ~/.saphan/config's identity.actor — the canonical source) |
--machine | string | unset | machine id to patch, e.g. m1 (required; must already be admitted) |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) |
--worktrees-root | string | unset | ABSOLUTE parent directory of this machine's stream worktrees, ON THAT MACHINE (required) |
saphan machine trust-stamp
Stamp (or --revoke) the vendor's own trust-dialog key for a seat, derived from an owner act
Sets projects["<resolved project root>"].hasTrustDialogAccepted = true
inside a seat's own <config-dir>/.claude.json — the ONE key the vendor
claude-code CLI actually reads before it skips its interactive trust
prompt for a directory (measured: the git MAIN repo root when the
directory lies in a git repo, worktree included; the directory itself
otherwise — never an ancestor, trust does not inherit).
The seat's file is a GUEST's to patch, never a re-write: every other byte of an existing .claude.json is preserved untouched (a byte-span splice, never a json.Marshal round-trip).
A stamp is admissible ONLY for a root that lies inside — or is equal to — an owner-declared anchor. WHICH anchor depends on the machine's own transport (never invented either way, this engine only derives):
- transport=local: the OWNER's OWN trust map (
~/.claude.json, this process's own). - transport=ssh: the machine's own registry-declared worktrees root
(
Machine.WorktreesRoot, set bymachine admit --worktrees-rootormachine set-worktrees-root— never this operator's local trust map, which cannot share a path prefix with a far host).
A root outside its lane's own anchor is refused by name (trust-stamp-not-derivable); a transport=ssh machine with NO declared worktrees root at all is refused by name too, distinctly (trust-stamp-worktrees-root-not-declared) — this engine derives permission, it never invents it.
--revoke undoes a stamp this engine previously placed for (seat, root): by default only when the root's own derived_from is no longer covered by its lane's own anchor (a health check); pass --force to revoke unconditionally (the owner's own explicit act).
No TTY and no --yes refuses and writes nothing — never a silent edit of the seat's own file.
This is an OWNER ACT, so rehearse it before handing the line over: saphan dry-run machine trust-stamp runs every read this real path runs and writes nothing, then prints the literal command for the owner's hand with the root already resolved. On a transport=ssh seat it makes no ssh dial, and says which two reads it therefore cannot answer.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--actor | string | unset | the owner actor performing this act (required; recorded with the stamp) |
--force | bool | false | with --revoke, revoke unconditionally even if derived_from is still covered by its lane's own anchor |
--identity | string | unset | seat identity to stamp trust for (required) |
--machine | string | unset | machine id the seat is on (required) |
--revoke | bool | false | revoke a previously-derived stamp instead of placing one |
--root | string | unset | project root to stamp (default: resolved from the current directory) |
--yes | bool | false | write without an interactive prompt (required when stdout is not a TTY) |
saphan machine unpin
Retire the fleet host-key pin for one machine (owner act) — removes, adopts nothing
Retires the pin(s) --machine's host holds in the fleet's own host-key
store (~/.saphan/ssh/known_hosts). This is the ONLY escape from a pin
adopted from the wrong host — a typo'd address, a machine rebuilt behind
the same name, a key picked up by an over-broad scan — that does not also
require permanently loosening host.<id>.ssh_host_key_policy for that host
(internal/hostkey's Reconcile refuses a mismatched key under policy=refuse
and only removes the old pin at all under policy=accept_rotation, which
then trusts every FUTURE change too; this verb removes the one pin without
touching policy).
It removes, and removes ONLY: no scan, no dial, no replacement key written.
After it runs, the host has NO pin — the next contact is first contact
again, and whichever verb performs that contact (machine admit or
machine pin) decides, separately, what to trust. This verb never
re-pins in the same act; that would reintroduce exactly the
adopt-without-looking hazard a bad pin protects against.
An unknown --machine refuses by name (machine-not-admitted,
the same class every other machine verb uses for the identical condition).
A machine that is not transport=ssh, or carries no ssh_host, has no host key
to begin with and refuses under machine pin's own classes
(machine-pin-not-ssh / machine-pin-ssh-host-missing) —
the same gap, the same remediation, whichever of the two verbs meets it
first.
Idempotent: a host with no pin already present is reported, not refused, and the command exits 0 without claiming a removal — running it twice is safe.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--machine | string | unset | machine id to unpin (required) |
saphan machine unseat
Free one slot by invalidating the seat row on it (owner act; NOT a custody act)
Frees ONE (machine, slot) pair: the executor identity seated there stops
being seated. Afterwards the machine's identity roster no longer carries it,
machine seat may put a DIFFERENT identity on that slot, and
machine retire stops refusing on account of that seat
(machine-retire-seated).
Append-only, like every other write in this registry: NO row is deleted. The seat is invalidated by a NEW row, and the whole history of who sat where stays in the log.
This is NOT an act of custody. It touches no cert, no key, no chain of trust,
and it does not revoke the identity's own credential — the identity keeps
existing and can be seated again, here or elsewhere. It withdraws an OBSADA
assignment only, exactly as machine retire withdraws a host from
the roster and machine set-slots changes a capacity.
Three refusals guard the act, each fail-closed and each naming the next one:
- THE PAIR IS NOT THIS IDENTITY'S SEAT (machine-unseat-not-seated-here): the refusal says where the identity ACTUALLY sits — or that it sits nowhere — and prints the command for that pair. Because the seat projection is keyed by identity, an identity holds at most one (machine, slot) pair fleet-wide, so this is a fact the registry can always supply rather than a guess.
- A LIVE RUN ON THAT SLOT (machine-unseat-live-run): a run_record row for this (machine, slot) with status=running and an empty ts_ended. The refusal names the run_id and its stream. Freeing a slot under a live run is the same class of loss as shrinking slots under a seat (machine-slots-below-seated) — the row goes away, the work does not. The question is asked of the RECORD, not of this Mac's process table: a transport=ssh run's pid belongs to the far host.
- THE MACHINE IS NOT ADMITTED (machine-not-admitted):
named, with
machine admitas the next act. A machine that was never admitted carries no slot to free.
WHY THIS ONE USES registry.ActRevoke WHEN machine retire REFUSES
TO — the two decisions sit in one file and look inconsistent, so here is the
reason they are not. A revoke row makes the projection DROP its subject
(registry's eachLatestLive skips a subject whose latest act is revoke). For a
MACHINE that is exactly wrong: a retired machine must stay readable, marked, or
machine list cannot show it and the next machine retire
cannot tell "already retired" from a typo'd id — hence a RetiredBy/RetiredAt
COLUMN there. For a SEAT the same behavior is exactly RIGHT: disappearing from
the roster IS what freeing a slot means, and it is what makes the slot
reusable, the retirement unblocked, and the identity re-seatable. Same
mechanism, opposite requirement, therefore opposite choice.
Options
| Option | Type | Default | Description |
|---|---|---|---|
--identity | string | unset | executor identity to unseat, e.g. fleet-3 (required) |
--machine | string | unset | machine id the identity is seated on, e.g. macm5 (required; must already be admitted) |
--removed-by | string | unset | the owner actor freeing this slot (required) |
--root-key | string | unset | root private key path (default: ~/.saphan/trust/root.key) |
--slot | int | 0 | slot index on --machine the identity occupies (required; never defaulted — slot 0 is a real slot) |