Saphan StudioDocs
Security

Worked examples

Real commands, real defaults, and the engine's own refusal classes quoted verbatim.

Real commands, real defaults, and the engine's own refusal classes quoted verbatim. Nothing below is illustrative pseudocode. Where a refusal is shown, that is the string the engine emits.

Founding the trust root

One time, on an air-gapped machine, with a witness present.

saphan ceremony root

The command opens with a confirmation, and nothing key-shaped is created before you answer:

ceremony root — owner team-birth (guided)
 Run this air-gapped, with a witness present for the read-back and the
 wipe below (RUNBOOK.md §1). Nothing key-shaped is created until you
 Air-gapped machine, witness present — continue? [y/N]

Declining is a clean exit: ceremony root: not confirmed — nothing was created.

On confirmation the engine mints the Ed25519 root, prints the key path and its pin (the public half you will distribute to verifiers), then prints the numbered word list — also saved to a file until the wipe. It is explicit about what it will not do:

QR rendering: not available in this build (no external qrencode binary is ever
invoked, and this build ships no QR dependency — true whether or not --no-qr is
passed) — falling back to the words+txt paper record below.

That sentence is deliberate. No key material is ever passed to an external process, not even a QR encoder.

Write the words on paper. Then the witness reads them back and the engine compares position by position. Only on an exact match are the key, its public sibling and the word list erased from disk.

Two flags exist and neither weakens the ceremony:

FlagEffect
--yesAccepts the air-gap/witness banner non-interactively. Never skips proof-of-capture.
--readback-file <path>Takes the read-back from a file instead of the terminal — for a witness on a separate machine. Pointing it at the ceremony's own word list is refused: ceremony-readback-self-referential.

Re-running the ceremony on a host that already has a root refuses with ceremony-root-exists. There is no force flag. Re-minting would silently invalidate every prior signature.

Then mint the intermediate CA, which does the day-to-day issuing:

saphan ceremony intermediate

P-256 keypair plus a self-signed CA certificate, 365 days by default (--days), written to ~/.saphan/trust/intermediate.key and intermediate.crt. Overwriting an existing intermediate requires --force.

Paths worth knowing — these are your backup and protection targets:

PathContents
~/.saphan/trust/root.keyRoot private key — absent after a successful ceremony, by design
~/.saphan/trust/root.key.pubThe root pin. Distribute this to anyone who needs to verify.
~/.saphan/trust/intermediate.key / .crtIntermediate CA — HSM or enclave in production
~/.saphan/machine/device.keyThat machine's own device key. Never leaves the machine.

Admitting a remote machine

Two steps, on two different hosts, because the device private key never moves.

Step one, on the machine being admitted:

saphan machine csr --machine m2 --csr-out m2.csr

Mints a P-256 device key at ~/.saphan/machine/device.key and builds a certificate signing request whose Common Name carries the machine id. For a later renewal, --reuse-key builds a fresh CSR from the existing key and the key file is never touched.

Copy m2.csr to the control plane. That is all that travels.

Step two, on the control plane:

saphan machine admit \
 --machine m2 --host-ref vm-linux-1 \
 --transport ssh --ssh-host 10.0.0.12 --ssh-user builder --slots 2 \
 --csr m2.csr \
 --admitted-by marcin

What happens, in order: the CSR's self-signature is checked; a short-lived certificate is issued through the intermediate; the certificate subject is compared with --machine; the SSH identity is probed live; and a root-signed machine row is appended to the identity log and projected into the machine registry.

Refusals you will meet, with the engine's own wording:

SituationRefusal
Certificate subject is not the machine idmachine-cert-subject-mismatch"cert subject does not match --machine", with the remediation "→ admit with the credential issued for this exact machine id"
No credential given"exactly one of --csr, --cert, or --mint-device-key is required"
Required identity fields missing"--machine, --host-ref, and --admitted-by are all required"
SSH transport with no hostmachine-ssh-host-required

Two defaults an administrator should know. --ssh-key autodetects ~/.ssh/id_ed25519 then ~/.ssh/id_rsa; --ssh-port is 22. And there is one deliberate escape hatch: --force-unverified writes the row even when the SSH identity fails its live probe — and annotates the row as unverified, so the record carries the fact rather than hiding it.

Confirm the result:

saphan machine list

which prints each admitted machine with its transport, slot count, seated identities, admitting actor and admission timestamp. Before any machine exists it says so plainly rather than printing an empty table: "machine registry — <path> (absent; no machines admitted yet)".

Admitting the control plane's own machine

The two-step dance is unnecessary when the machine is the one you are typing on:

saphan machine admit --machine m1 --host-ref mac-studio \
 --transport local --mint-device-key --admitted-by marcin

This mints the device key, builds the CSR, issues the certificate and admits, in one command. It is local-only and refuses --transport ssh; it refuses to combine with --csr or --cert; and if a device key already exists at the target path it refuses machine-device-key-exists with the remediation printed verbatim (naming --transport local explicitly, since this step falls under the same --transport guard the two-step dance above does, now that no transport is guessed by default):

→ a device key already exists at ~/.saphan/machine/device.key — reuse it instead of minting a new one (or point --device-key elsewhere to mint a fresh one):
machine csr --machine m1 --reuse-key --out ~/.saphan/machine/device.key --csr-out ~/.saphan/machine/device.key.csr
machine admit --csr ~/.saphan/machine/device.key.csr --machine m1 --host-ref mac-studio --admitted-by marcin --transport local

Seating identities — several accounts on one machine

This is the soft chroot from section 7, in practice. Two executor identities on one machine, two slots, two profile homes:

saphan machine seat --identity fleet-1 --machine m2 --slot 0 \
 --backend claude-code --added-by marcin

saphan machine seat --identity fleet-2 --machine m2 --slot 1 \
 --backend claude-code --added-by marcin

Each seat gets its own profile home on the far host, defaulting to $HOME/.claude-<identity> — or $HOME/.codex-<identity> when --backend codex. Override with --config-dir. Now authenticate each seat once, inside its own profile home, and the two accounts coexist: separate credentials, separate sessions, separate history, running concurrently.

Each slot carries one concurrent run unless you say otherwise. To let a seat run several sessions at once — the usual reason being a subscription that permits it — set its capacity explicitly, per slot:

saphan machine set-max-sessions --machine m2 --slot 0 --max-sessions 5 --added-by marcin

Slot 1 is unaffected: capacity belongs to the slot, so two accounts on one machine can carry different numbers. A dispatch onto a slot already at capacity is refused, never queued, and the refusal names both numbers.

The slot index must fall within the machine's admitted slot count. And when a run is later cast onto a seat, its --backend must match the seat's, or the cast refuses seat-backend-mismatch — because exporting a profile home under a variable the other tool never reads would land the session back in the machine's shared directory, silently.

The seat's --role binds the same way. It defaults to executor and takes one of master | executor | review | design | scribe | advisor. That default matters more than a default usually does: executor is UNRESERVED — nobody deliberately chose it, and every seat that carries it (8 of 8, as of this writing) takes ANY declared role, including none. Any OTHER value RESERVES the seat: someone deliberately signed it for one role, and a run cast onto it is placed only if it declares that exact role. Two things follow, and both are deliberate:

  • A run that declares no --role at all is placed onto an UNRESERVED (executor) seat. An absent role is an honest unknown there, not a mismatch — the first dispatch into a new stream carries none, so refusing it would leave no legal way to open one.
  • A run that declares no --role at all is REFUSED at placement (R25) onto a RESERVED seat, exactly as a run declaring a mismatched role is. Dropping --role does not unbind a run from a seat someone reserved — there is no flagless way onto it.
  • A seat with no registered role takes no role-declaring run. An unknown is never an all-clear; re-seat the identity with an explicit --role (below) to make it dispatchable again.
  • A run that declares no --role at all is placed when no seat resolves for it, or when the seat that does resolve carries no registered role (owner ruling, 2026-08-10). Slot topology — no such seat, or two identities colliding at one slot — is R8c's question, not R25's, and a role-less dispatch puts no role question for R25 to answer in the first place.

Every R25 refusal is recorded, exit 3 — never placed silently, and never quietly downgraded to the seat's own role. Re-seating is one remediation, and it is an owner act — the refusal prints this line filled in with the run's own values:

saphan machine seat --identity fleet-1 --machine m2 --slot 0 \
 --role review --backend claude-code --added-by marcin

The other way out is to route the run to a seat already registered for the role it declares — or, for a run declaring none, to an UNRESERVED (executor) seat; saphan machine list prints each seat's role beside its slot. Declaring a --role to get onto a reserved seat also ARMS R8d, the (stream, role) dispatch lock (R8d is a no-op only while no role is declared) — that remedy is for a stream carrying no live run of that role, not a blanket fix.

Proving a capability before work is routed there

A manifest claim is not evidence. Turn it into evidence:

saphan machine probe --machine m2 --capability gradle \
 --declared-path /usr/bin/gradle --workhome /home/builder/work --probed-by m0

The flag help states the model better than a paragraph would: --declared-path is "the manifest's declared absolute path for this capability's binary — a claim, never evidence; the probe proves or refutes it", and --declared-version is "carried as provenance, never compared for ranking". --probed-by defaults to the control plane and is documented as "the actor that FIRED this probe (never the subject machine)" — declaring and proving are two different parties, and the record keeps them apart.

The probe runs one row of a closed catalogue and never installs. Until it passes, that capability stays at declared and the machine will not receive the corresponding work.

The container probe uses the same ceremony, but proves an outcome for a pinned image digest:

saphan machine probe --machine m2 --capability docker-oci \
 --declared-path /usr/bin/podman --workhome /home/builder/work \
 --probed-by m0 --workspace /Users/Sources/saphan/saphan-studio

On success the command reports machine probe: m2/docker-oci -> pass. The signed record then contains a probe-confirmed docker-oci capability, a separate image-capability row keyed by the exact digest the runtime says it started, and the probe-observed machine facts runtime, vm_cpus and vm_memory_bytes. Only that successful probe writes the image-specific row and machine facts. Its lease is evaluated when a cast reads it, so expiry makes the exact-digest row ineligible without storing a separate "currently stale" decision.

A container cast with no fresh row for its exact digest refuses as RS4A-NO-CAPABLE; the refusal prints a complete saphan machine probe remediation populated with the actual machine, runtime, work-home and workspace. Follow the refusal rather than composing a replacement command from memory. To find the arc's silent-failure condition — a container run that happened without the required measurement at its own start time — run:

saphan checks container-contradictions \
 --workspace /Users/Sources/saphan/saphan-studio

The check prints checks container-contradictions: ok only when it finds no contradiction. A later probe cannot make an earlier unmeasured run valid.

Delegating signing authority, then signing and verifying

Grant a delegate a time-boxed window:

saphan trust delegate \
 --delegate-actor scribe --delegate-key-id apk-<fingerprint> \
 --delegate-pub <base64 public key> \
 --nbf 2026-08-07T00:00:00Z --exp 2026-08-14T00:00:00Z

The grant is root-signed into the registry's delegations section, and the signature covers every field including the window and the streams — so it cannot be widened afterwards. Note that both bounds are required: there is no open-ended grant.

The delegate signs a document's exact bytes, producing a detached sidecar — and, in the same act, registers those bytes so they have an address (§13.10, which is where the preconditions and the refusals live):

saphan order sign path/to/order.md --key ~/.saphan/keys/scribe.key --actor scribe

Anyone can verify against the chain:

saphan verify order-slug@0123456789ab --stream <stream-name>

An input containing @ is a registered order ref. A full 64-character digest is also accepted when paired with --ref. A path remains accepted; its class is inferred from whether a .sig sidecar is present. --stream is what the delegation's scope is matched against.

For an order, PASS is one indivisible predicate: the signature chain is sound, a registration exists, the registered commit still yields the recorded digest, and the projected registration status is active. A valid signature without a registration refuses as order-unregistered; a registration without a sidecar refuses on the existing instr: order has no sidecar signature error. Verification prints only authenticated fields.

For standing law, ratify a set of documents into a root-signed manifest, then verify a single document against it:

saphan laws ratify docs/law/*.md --note "ratified at the 2026-08 review"
saphan laws cat docs/law/dispatch.md

Health-checking the trust material

saphan trust doctor

Checks that the root pin is present and loadable, that key custody guards are clean (owner-only permissions, no symlink substitution, correct length), and that the instruction-enforcement setting holds a valid value. Run it on any machine that signs or verifies.

Naming a seat's billing class

This is the economic guard from section 7, in practice. Seat an identity onto a subscription account:

saphan machine seat --identity fleet-1 --machine m2 --slot 0 \
 --backend claude-code --billing-class subscription --added-by marcin

And a second identity, on a metered, pay-per-token account:

saphan machine seat --identity fleet-2 --machine m2 --slot 1 \
 --backend claude-code --billing-class metered --added-by marcin

And a third, on a model endpoint the owner runs directly, with no vendor account to meter:

saphan machine seat --identity fleet-3 --machine m2 --slot 2 \
 --backend claude-code --billing-class local-energy --added-by marcin

--billing-class accepts exactly subscription, metered, or local-energy; anything else refuses named, nothing written:

→ --billing-class must be one of the closed vocabulary: subscription | metered | local-energy
(omit the flag to leave the seat unclassified — billing class is the owner's act, never a
guess):
machine seat: refused (machine-seat-billing-class-unknown)

Omitting the flag entirely is legal — the seat is written with no billing class at all, and every read of it, from machine list to fleet show, projects unknown. This is the correct state for "I haven't decided yet"; it is deliberately not the same as subscription, and nothing in this system treats it that way. Changing a seat's billing class later is the same act again — a fresh machine seat for that identity, naming the new class — appended to the signed log like any other seating.

Measuring a remote machine's confinement capability

Before a machine can take engine-confined work over SSH, its confinement capability must be measured — never declared:

saphan machine probe-confinement --machine m2

The probe dials the machine over its own recorded transport, measures the kernel's Landlock level live, and writes the result onto the machine's signed registry row. There is no flag that accepts a hand-typed level and no override. saphan machine list then prints the measured level beside the machine — or that the machine is incapable or unprobed. A remote confined dispatch reads that row as a gate: unprobed, incapable, or below the floor all refuse by name, with the probe command as the remediation.

Registering an order

Signing proves who wrote these bytes. Registration answers the other question — is this the current order, and which exact bytes were they? — by recording an immutable row: the document's digest, the commit that carries it, who signed it, and until when it stands. What you get back is a ref: a short, hand-copyable address you can paste instead of the document's body.

saphan order sign performs both halves. There is no separate register verb and no flag that turns registration off, because a signed document nobody can address is the state this mechanism exists to remove.

1. Commit the document first. Registration records a commit, so the bytes must already be in the repository's history. (The .sig sidecar need not be committed.)

git -C /path/to/repo add briefs/_MASTER_ORDER.md
git -C /path/to/repo commit -m "order: register the dispatch brief"

2. Sign and register in one act.

saphan order sign /path/to/repo/briefs/_MASTER_ORDER.md \
 --key ~/.saphan/keys/scribe.key --actor scribe

The verb prints two lines: the sidecar it wrote, and the ref it registered, e.g. ref master-order@1f0e3dad9990 (commit 9eff90a80000 · status active). Keep the ref — that string is the address.

3. Name the registration yourself, or bound its life. --slug replaces the default name (the document's basename stem); --expires takes an RFC3339 instant after which the registration reads expired with no further act by anyone.

saphan order sign /path/to/repo/briefs/_MASTER_ORDER.md \
 --key ~/.saphan/keys/scribe.key --actor scribe \
 --slug order-registration-store --expires 2026-09-16T00:00:00Z

4. Re-running is safe. Re-signing the same bytes finds the existing registration and writes nothing — the verb says already registered and prints the same ref. Re-signing changed bytes is a new digest and therefore a new registration; the old one is untouched and stays resolvable, which is what makes a ref an address rather than a pointer at whatever the file says today.

5. Verify the registered predicate. Prefer the ref the signing act printed:

saphan verify master-order@1f0e3dad9990 --stream <stream-name>

Use --ref for a bare full digest. You may also pass the document path, but an order path does not weaken the check: it must still resolve to an active registration whose committed bytes match. show and verify deliberately emit the same order-unregistered class for a missing registration, so automation does not have to translate between two names for one condition.

What you may be told, and what to do about it

the verb sayswhat happenedyour next act
order-uncommittedThe signature was written, but no landed commit carries these exact bytes.Commit the document (step 1) and re-run. The sidecar stays on disk — it is correct, and re-running simply rewrites it.
owner-key-absentThe registration row is signed by the workspace key, and this session has none.Run from the workspace that owns the record, or mint this workspace's key with saphan actor initFirst mint, below). saphan actor list shows the key but never mints it — it is a read. Nothing was written.
no-workspaceNothing resolved a workspace to record into.Re-run with --workspace <path>.

A registration's currency is one of exactly four wordsactive, superseded, revoked, expired — and it is computed from an append-only log of acts, never stored as a mutable field. Nothing overwrites a registration; withdrawal is a new act, which is why the record can still answer what did this order say when that run was dispatched?