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:
| Value | Tiers, 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 key | file → 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:
- A workspace that declares itself wins.
<workspace>/.saphan/workspace.jsoncarries{"contract":"saphan.workspace/v1"}. Nothing is inferred — the install directory simply never has this file. - Otherwise workspace state wins. A
.saphan/holding any ofstate/,work/,union/,trunk/,verify/,secret,merge-probe-land.lockis a workspace. - Otherwise installer entries veto. A
.saphan/holding any ofbin/,repos/,workspaces/,install-src/,dist/,trust/,machine/,etc/,logs/,run/— or the older installer spellingsconfigandssh/— and none of the entries in rung 2 is the install directory, and the walk-up passes over it and keeps climbing. - 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 initandsaphan 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:
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:
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
$HOMEwith no--workspaceis 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
printfabove 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:
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 meant | Do this |
|---|---|
| a workspace that already exists | --workspace <path> (or SAPHAN_WORKSPACE), or cd into it |
| a new workspace here | saphan 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 workspace | declare 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/statecomposed 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 versionandsaphan config shownever 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.