Saphan StudioDocs
Reference/Command reference

saphan machine

The machine registry: admitted hosts, slots, and their executor identities

saphan machine

The machine registry: admitted hosts, slots, and their executor identities

saphan machine

Subcommands

SubcommandWhat it does
saphan machine admitAdmit a machine to the CA (owner act, D3: one-time per machine)
saphan machine csrDevice-side: mint (or reuse) this machine's P-256 key and build its CSR
saphan machine deliver-branchDeliver one named branch to a machine's admission mirror and shared clone
saphan machine listList admitted machines with their slots and seated executor identities
saphan machine loginConduct a seat's vendor login where the seat lives, then verify it — the operator never types a path
saphan machine observeRead what a machine is running now, from that machine
saphan machine pinSeed the fleet host-key pin store for an already-admitted machine (owner act)
saphan machine probeRun one v0 catalog probe against a declared toolchain and record the result (RS-3, never installs)
saphan machine probe-confinementMeasure a machine's filesystem-confinement capability on its own host and record it (never installs, never accepts a typed value)
saphan machine refresh-repoRefresh one machine's admission mirror and its shared clone
saphan machine renewReissue a cert, or patch the ssh transport, for an already-admitted machine (owner act)
saphan machine retireWithdraw an admitted machine from the fleet roster (owner act; NOT a custody act)
saphan machine seatSeat an executor identity on an admitted machine's slot (owner act; cast-surface write path)
saphan machine set-binaryRegister this machine's agent binary path for a backend (owner act; NOT a custody act)
saphan machine set-max-sessionsChange how many concurrent runs one slot of an admitted machine may carry (owner act; NOT a custody act)
saphan machine set-priceAdmit a machine-wide USD/hour rate for an admitted machine (owner act; NOT a custody act)
saphan machine set-repo-dirRegister the absolute bare admission-repo path on a machine, per repo (owner act; NOT a custody act)
saphan machine set-slotsChange how many executor-identity slots an admitted machine carries (owner act; NOT a custody act)
saphan machine set-worktrees-rootRegister the absolute directory this machine keeps its stream worktrees in (owner act; NOT a custody act)
saphan machine trust-stampStamp (or --revoke) the vendor's own trust-dialog key for a seat, derived from an owner act
saphan machine unpinRetire the fleet host-key pin for one machine (owner act) — removes, adopts nothing
saphan machine unseatFree 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)

saphan machine admit [flags]

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

OptionTypeDefaultDescription
--admitted-atstringunsetRFC3339 admission timestamp (default: now)
--admitted-bystringunsetthe owner actor performing this admission (required)
--binarystringArray[]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
--certstringunsetan already-issued short-lived cert file (PEM or DER) — presented as-is
--csrstringunsetdevice CSR file (PEM or DER) — issues a fresh short-lived cert via the intermediate
--device-keystringunsetdevice private key write path for --mint-device-key (default: ~/.saphan/machine/device.key)
--force-unverifiedboolfalsewrite the machine row even if the ssh key fails a live probe (annotates the row as unverified; transport=ssh only)
--gomodcache-rootstringunsetABSOLUTE 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-refstringunsethost reference, e.g. vm-linux-1 (required)
--intermediate-certstringunsetintermediate CA cert path (default: ~/.saphan/trust/intermediate.crt; only used with --csr)
--intermediate-keystringunsetintermediate private key path (default: ~/.saphan/trust/intermediate.key; only used with --csr)
--machinestringunsetmachine id to admit, e.g. m1 (required)
--max-sessionsint1concurrent-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-keyboolfalseone-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-keystringunsetroot private key path (default: ~/.saphan/trust/root.key)
--slotsint1executor-identity slot count
--ssh-hoststringunsetssh HostName (required when --transport ssh)
--ssh-keystringunsetssh IdentityFile path (default: autodetect ~/.ssh/id_ed25519, then ~/.ssh/id_rsa)
--ssh-portint22ssh Port
--ssh-userstringmmarzecssh User (default: current user)
--transportstringunsettransport 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
--ttlduration24h0m0sshort-lived cert TTL when issuing via --csr
--worktrees-rootstringunsetABSOLUTE 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

saphan machine csr [flags]

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

OptionTypeDefaultDescription
--csr-outstringunsetCSR write path (default: <machine-id>.csr in the current directory)
--machinestringunsetmachine id this CSR's CN will carry — must match --machine at admission (required)
--outstringunsetdevice private key path (default: ~/.saphan/machine/device.key) — written on mint, only READ with --reuse-key
--reuse-keyboolfalsebuild 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

saphan machine deliver-branch <machine> [flags]

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

OptionTypeDefaultDescription
--branchstringunsetnamed local branch to deliver (required; never guessed)
--repostringunsetABSOLUTE 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

saphan machine list

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

saphan machine login [flags]

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:

  1. resolves the seat from --machine (never guessing when a machine carries several seats — it refuses and names them);
  2. names the credential entry it is about to write, and every SIBLING entry this seat already owns;
  3. 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;
  4. reaps that session when the operator exits;
  5. 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

OptionTypeDefaultDescription
--identitystringunsetwhich seat, when the machine carries more than one (refused as ambiguous rather than guessed)
--machinestringunsetmachine id the seat lives on (required — and the ONLY thing that is)
--slotint0the seat's slot, when it is named explicitly; must agree with the identity's own seated slot
--verify-onlyboolfalsemeasure the seat's credential and report; conduct no login (the negative control)
--verify-timeoutduration2m0swall clock for the verification probe

saphan machine observe

Read what a machine is running now, from that machine

saphan machine observe --machine <machine-id> [flags]

Options

OptionTypeDefaultDescription
--jsonboolfalsemachine-readable JSON
--machinestringunsetregistered machine to measure

saphan machine pin

Seed the fleet host-key pin store for an already-admitted machine (owner act)

saphan machine pin [flags]

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

OptionTypeDefaultDescription
--allboolfalsepin every transport=ssh machine currently in the registry
--machinestringunsetmachine 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)

saphan machine probe [flags]

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

OptionTypeDefaultDescription
--capabilitystringunsetcapability name from the closed v0 catalog, e.g. gradle (required)
--declared-pathstringunsetthe manifest's declared absolute path for this capability's binary (required) — a claim, never evidence; the probe proves or refutes it
--declared-versionstringunsetthe manifest's declared version, if any — carried as provenance, never compared for ranking
--machinestringunsetmachine id this probe fires on, e.g. m2 (required)
--probed-bystringm0the actor that FIRED this probe (never the subject machine)
--root-keystringunsetroot private key path (default: ~/.saphan/trust/root.key) — an automatic renewal signs with the same key material admission uses
--workhomestringunsetthis 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)

saphan machine probe-confinement <machine> [flags]

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

OptionTypeDefaultDescription
--machinestringunsetmachine id to probe (alias for the positional argument; must already be admitted, transport=ssh or transport=local)
--probed-bystringm0the actor that FIRED this probe (never the subject machine) — recorded as the row's probe provenance
--root-keystringunsetroot private key path (default: ~/.saphan/trust/root.key)

saphan machine refresh-repo

Refresh one machine's admission mirror and its shared clone

saphan machine refresh-repo <machine> [flags]

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

OptionTypeDefaultDescription
--repostringunsetABSOLUTE 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)

saphan machine renew [flags]

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:

  1. 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).
  2. 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.
  3. --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 GOMODCACHE path — 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

OptionTypeDefaultDescription
--certstringunsetan already-issued short-lived cert file (PEM or DER) — presented as-is (cert-reissue shape; omit together with --csr for a transport-only patch)
--csrstringunsetdevice 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-unverifiedboolfalsewrite the renewed row even if the ssh key fails a live probe (annotates the row as unverified; transport=ssh only)
--gomodcache-rootstringunsetABSOLUTE 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-certstringunsetintermediate CA cert path (default: ~/.saphan/trust/intermediate.crt; only used with --csr)
--intermediate-keystringunsetintermediate private key path (default: ~/.saphan/trust/intermediate.key; only used with --csr)
--machinestringunsetmachine id to renew, e.g. m1 (required)
--renewed-atstringunsetRFC3339 renewal timestamp (default: now)
--renewed-bystringunsetthe owner actor performing this renewal (required)
--root-keystringunsetroot private key path (default: ~/.saphan/trust/root.key)
--ssh-hoststringunsetssh HostName override (transport=ssh only; default: inherit the current row's value; alone, with no --csr/--cert, this is a transport-patch renewal)
--ssh-keystringunsetssh IdentityFile override (transport=ssh only; default: inherit the current row's value; alone, with no --csr/--cert, this is a transport-patch renewal)
--ssh-portint0ssh Port override (transport=ssh only; default: inherit the current row's value; alone, with no --csr/--cert, this is a transport-patch renewal)
--ssh-userstringunsetssh User override (transport=ssh only; default: inherit the current row's value; alone, with no --csr/--cert, this is a transport-patch renewal)
--ttlduration24h0m0sshort-lived cert TTL when issuing via --csr

saphan machine retire

Withdraw an admitted machine from the fleet roster (owner act; NOT a custody act)

saphan machine retire [flags]

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 seat onto 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

OptionTypeDefaultDescription
--machinestringunsetmachine id to retire, e.g. macm5b (required; must already be admitted)
--retired-bystringunsetthe owner actor performing this retirement (required)
--root-keystringunsetroot 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)

saphan machine seat [flags]

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

OptionTypeDefaultDescription
--added-atstringunsetRFC3339 seating timestamp (default: now)
--added-bystringunsetthe owner actor performing this seating (required)
--backendstringclaude-codewhich 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-classstringunsetwhich 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-dirstringunsetprofile 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
--identitystringunsetexecutor identity to seat, e.g. fleet-1 (required)
--lanestringunsetconfig-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)
--machinestringunsetmachine id to seat the identity on, e.g. m1 (required; must already be admitted)
--move-fromstringunsetthe 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)
--rolestringexecutorthe 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-keystringunsetroot private key path (default: ~/.saphan/trust/root.key)
--slotint0slot 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)

saphan machine set-binary [flags]

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

OptionTypeDefaultDescription
--actorstringunsetone-off override of the write's actor (default: env SAPHAN_ACTOR, then ~/.saphan/config's identity.actor — the canonical source)
--binarystringArray[]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)
--machinestringunsetmachine id to patch, e.g. m1 (required; must already be admitted)
--root-keystringunsetroot 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)

saphan machine set-max-sessions [flags]

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 seat and 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-slots and machine renew already 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

OptionTypeDefaultDescription
--added-bystringunsetthe owner actor performing this change (required)
--machinestringunsetmachine id to patch, e.g. macm5 (required; must already be admitted)
--max-sessionsint0concurrent-run capacity for this ONE slot (required; must be >= 1)
--root-keystringunsetroot private key path (default: ~/.saphan/trust/root.key)
--slotint-1which 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)

saphan machine set-price [flags]

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-sessions and machine set-slots already apply — a write that changes nothing is refused rather than appended as a row a later reader must prove is a no-op.

Options

OptionTypeDefaultDescription
--added-bystringunsetthe owner actor performing this change (required)
--machinestringunsetmachine id to patch, e.g. macm5 (required; must already be admitted)
--price-usd-per-hourfloat640machine-wide USD/hour rate (required; must be >= 0)
--root-keystringunsetroot 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)

saphan machine set-repo-dir [flags]

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

OptionTypeDefaultDescription
--actorstringunsetone-off override of the write's actor
--machinestringunsetmachine id to patch (required; must already be admitted)
--repostringunsetWHICH 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-dirstringunsetABSOLUTE path of the bare admission repo on that machine (required)
--root-keystringunsetroot 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)

saphan machine set-slots [flags]

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 retire writes — 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 seat would 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 renew applies — a write that changes nothing is refused rather than appended as a row a later reader must prove is a no-op.

Options

OptionTypeDefaultDescription
--added-bystringunsetthe owner actor performing this change (required)
--machinestringunsetmachine id to patch, e.g. macm5 (required; must already be admitted)
--root-keystringunsetroot private key path (default: ~/.saphan/trust/root.key)
--slotsint0how 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)

saphan machine set-worktrees-root [flags]

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

OptionTypeDefaultDescription
--actorstringunsetone-off override of the write's actor (default: env SAPHAN_ACTOR, then ~/.saphan/config's identity.actor — the canonical source)
--machinestringunsetmachine id to patch, e.g. m1 (required; must already be admitted)
--root-keystringunsetroot private key path (default: ~/.saphan/trust/root.key)
--worktrees-rootstringunsetABSOLUTE 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

saphan machine trust-stamp [flags]

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 by machine admit --worktrees-root or machine 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

OptionTypeDefaultDescription
--actorstringunsetthe owner actor performing this act (required; recorded with the stamp)
--forceboolfalsewith --revoke, revoke unconditionally even if derived_from is still covered by its lane's own anchor
--identitystringunsetseat identity to stamp trust for (required)
--machinestringunsetmachine id the seat is on (required)
--revokeboolfalserevoke a previously-derived stamp instead of placing one
--rootstringunsetproject root to stamp (default: resolved from the current directory)
--yesboolfalsewrite 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

saphan machine unpin [flags]

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

OptionTypeDefaultDescription
--machinestringunsetmachine id to unpin (required)

saphan machine unseat

Free one slot by invalidating the seat row on it (owner act; NOT a custody act)

saphan machine unseat [flags]

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 admit as 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

OptionTypeDefaultDescription
--identitystringunsetexecutor identity to unseat, e.g. fleet-3 (required)
--machinestringunsetmachine id the identity is seated on, e.g. macm5 (required; must already be admitted)
--removed-bystringunsetthe owner actor freeing this slot (required)
--root-keystringunsetroot private key path (default: ~/.saphan/trust/root.key)
--slotint0slot index on --machine the identity occupies (required; never defaulted — slot 0 is a real slot)