Saphan StudioDocs
Configuration

Precedence and workspace resolution

The three ladders, what counts as a workspace marker, and what happens where nothing declared one.

Three ladders coexist, and --provenance shows which tier won:

ValueTiers, strongest first
identity.actor--actor flag → SAPHAN_ACTOR env → file → unset (an act verb refuses)
server.listen--listen flag → SAPHAN_SERVER_LISTEN env → file → built-in default 127.0.0.1:7654
every other config keyfile → unset (or the key's built-in default, named on Every key)

Separately, the CLI's global flags each have an environment twin named SAPHAN_<FLAG> (dash → underscore): SAPHAN_WORKSPACE, SAPHAN_PROJECT, SAPHAN_STATE_DIR, SAPHAN_MERGED_REF, SAPHAN_JSON, SAPHAN_OUTPUT_FORMAT, SAPHAN_VERBOSE. The ladder is always flag → env → config → default. (--text and --color have no environment twin; colour also follows NO_COLOR and TERM=dumb.)

Workspace resolution deserves its own sentence, because every fleet verb depends on it: --workspace (or SAPHAN_WORKSPACE) is explicit; otherwise the engine walks up from the current directory looking for a .saphan/ marker; if neither resolves, fleet verbs refuse rather than guessing. Every invocation echoes on stderr which workspace resolved and why.

What counts as a workspace marker

.saphan is also the name of the engine's own install directory, ~/.saphan — where your binaries, configuration, trust material and machine identity live. A walk-up that tested only the directory NAME would report your entire home directory as one giant workspace, so the engine tells the two apart:

  1. A workspace that declares itself wins. <workspace>/.saphan/workspace.json carries {"contract":"saphan.workspace/v1"}. Nothing is inferred — the install directory simply never has this file.
  2. Otherwise workspace state wins. A .saphan/ holding any of state/, work/, union/, trunk/, verify/, secret, merge-probe-land.lock is a workspace.
  3. Otherwise installer entries veto. A .saphan/ holding any of bin/, repos/, workspaces/, install-src/, dist/, trust/, machine/, etc/, logs/, run/ — or the older installer spellings config and ssh/ — and none of the entries in rung 2 is the install directory, and the walk-up passes over it and keeps climbing.
  4. Anything else — including a bare, empty .saphan/ — is a workspace.

A linked git worktree is never a workspace, whatever its .saphan/ holds and even if it carries a workspace.json. A checkout of the code can carry a .saphan/work/ directory with the branch it tracks, so the walk-up declines a linked worktree before asking any rung, and naming one with --workspace is refused by name with the canonical root to use instead.

You do not have to do anything. Existing workspaces keep resolving through rungs 2 and 4, and the declaration is written for you by the verbs that create a workspace or write into it:

  • saphan bridge init and saphan init, when they lay out the workspace.
  • saphan run, when it creates that run's work home under <workspace>/.saphan/work/.

No other verb stamps. In particular saphan gate and saphan claim do not: opening a workspace's store is not the same thing as writing to that workspace (gate --dry-run opens the store and must write nothing at all). If you want the declaration now without running one of those verbs, create the file by hand:

printf '{"contract":"saphan.workspace/v1"}\n' > <workspace>/.saphan/workspace.json

The declaration is also the override: a directory the entry-shape rungs would get wrong resolves correctly the moment it declares. The engine never rewrites a declaration it did not author, so a file carrying a future contract version is left alone.

The engine will not treat its own install directory as a workspace, and refuses if asked. Name a .saphan that carries installer entries and no workspace state — --workspace $HOME on a machine with the engine at ~/.saphan, say — and any verb refuses by name, before it runs:

$ saphan fleet show --workspace $HOME
saphan: wsroot: refusing to declare the engine's install directory as a workspace:
  /home/you/.saphan carries bin trust machine etc and no workspace state
  (state work union trunk verify secret merge-probe-land.lock)
→ if you meant a workspace, name that workspace's own root — not /home/you, which is
  where the engine itself is installed
→ if this directory really is a workspace, say so by hand and the walk-up will
  believe it:
    printf '{"contract":"saphan.workspace/v1"}\n' > /home/you/.saphan/workspace.json

The refusal happens at workspace resolution — the one door every verb passes — so nothing under the named root (no state directory, no work home, no declaration) is created before the answer. Opening the state store creates <workspace>/.saphan/state/, and state/ is a rung-2 entry, so asking any later would make the install directory a workspace by the act of asking.

Three consequences worth knowing:

  • Discovery is unaffected. The walk-up already passes over an install-shaped marker (rung 3), so a discovered workspace never meets this refusal.
  • Running from $HOME with no --workspace is not refused at resolution — it is refused one step later, at the store; the next section is the whole of that story. Verbs that need no workspace (saphan version, saphan config show) keep working from anywhere.
  • A hand-written declaration still wins. The printf above is the override, honoured at this door exactly as it is by the walk-up: a human saying "this really is a workspace" is believed, and only the engine's own automatic stamp is withheld.

The refusal exists because a declaration is one-way: nothing un-declares, no verb removes the file, and rung 1 sits above rung 3's veto — so a wrongly stamped install directory would turn your whole home directory back into one giant workspace, permanently.

The older marker .task-studio/ is still recognized, on the same terms.

Running where nothing declared a workspace

If you run a verb that touches workspace state from a directory where the walk-up found no marker, and you passed neither --workspace nor SAPHAN_WORKSPACE, the engine refuses instead of creating a workspace under your current directory:

$ cd ~            # or anywhere with no .saphan above it
$ saphan ledger show
saphan: no workspace: no .saphan marker walking up from /home/you, no --workspace,
  no SAPHAN_WORKSPACE — refusing to create workspace state in a directory nothing
  declared a workspace

Why this is a refusal and not a convenience. Creating <cwd>/.saphan/state/ on the way would make that directory answer "workspace" to every later walk-up, permanently — one read-only-looking verb would mint a workspace nobody asked for.

Naming a root that is not a workspace is refused the same way. --workspace <path> (or SAPHAN_WORKSPACE) that names a directory with no marker and no declaration — a typo, say — is refused at the store with what is actually on disk at that path and, when one exists, the nearest real workspace above it. Nothing is created under the named root.

A BLANK declaration is no declaration. SAPHAN_WORKSPACE= (set, but empty), --workspace= and a whitespace-only value all name no directory, so they resolve exactly as if you had passed nothing: the walk-up runs, and from an undeclared directory you get the refusal above, with a line naming the layer that was blank. This is the shape a script produces when it passes an unset variable down. The same holds for SAPHAN_STATE_DIR= and --state-dir=.

Three ways to proceed, and you pick:

You meantDo this
a workspace that already exists--workspace <path> (or SAPHAN_WORKSPACE), or cd into it
a new workspace heresaphan bridge init --workspace <path> — it creates and declares the workspace, which is the act that makes this directory an answer to the walk-up. The flag is required: with nothing named it refuses on this same class rather than creating one under your current directory
this directory really is a workspacedeclare it by hand: printf '{"contract":"saphan.workspace/v1"}\n' > .saphan/workspace.json

What is NOT refused:

  • --state-dir / SAPHAN_STATE_DIR. Naming the state directory is saying where state goes, so it is honoured even from an undeclared directory. Only the DERIVED default — <current directory>/.saphan/state composed because nothing else resolved — is refused. A blank value names nothing and so is not a naming: it falls through to the derived default and is refused with it.
  • Verbs that need no workspace. saphan version and saphan config show never open a store (the configuration is per operator), so they keep working from anywhere, including a host's own $HOME. This is why the refusal sits at the store rather than at resolution: those verbs survive by never reaching the door, not by being on an exemption list.
  • A workspace found by walking up. Discovery resolved something; nothing was derived from an undeclared directory.

On this page