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 below) |
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.
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, fleet trust material, machine identity and worktrees live (§ Install).
A walk-up that tested only the directory NAME therefore reported your entire home
directory as one giant workspace on any machine with the engine installed, and every path
under $HOME "discovered" it. The engine now 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/,config,ssh/— 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, exactly as before.
You do not have to do anything. Existing workspaces keep resolving through rungs 2
and 4, and the declaration is written for you by exactly two verbs — the two moments the
engine knows, without inferring, that this .saphan is a workspace's:
saphan bridge init, when it creates 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), so the stamp lives at the two unambiguous
mutations above and nowhere else. If you want the declaration now without running either,
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:
(Exit 1, the same code every other bad global value resolves to — the invocation was refused before the verb ran, not by the verb.)
The refusal happens at workspace resolution — the one door every verb passes — and
that placement is the whole point, not a detail. It used to be asked only by the two
stamping verbs, and by then it was too late to be honest: opening the engine's state store
creates <workspace>/.saphan/state/ unconditionally, state/ is a rung-2 workspace
entry, and saphan run opens its store hundreds of lines before it reaches the stamp. One
mistyped --workspace was enough to make the install directory a workspace by the act of
asking whether it was one. Asking at resolution means nothing under the named root — no
state dir, no work home, no declaration — is created before the answer.
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 — including a worktree
parked at
~/.saphan/workspaces/wt-*, which keeps resolving to itself. - Running from
$HOMEwith no--workspaceis not refused at RESOLUTION — it is refused one step later, at the store, and §3.2 below 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 silent 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 pre-D18 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. It used to succeed. saphan ledger show
exited 0, printed an empty ledger, and created <cwd>/.saphan/state/ on the way — and
state/ is a rung-2 workspace entry (§3.1), so that one read-only-looking verb made the
directory it ran in answer "workspace" to every later walk-up, permanently. Nothing asked
for a workspace; a workspace was minted. Run from $HOME on a fleet host, it re-created
the whole "your home directory is one giant workspace" defect that §3.1 exists to fix.
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 rather
than a workspace minted under a relative .saphan/state. This is the shape a script or a
spawned session produces when it passes an unset variable down — it is refused on the same
terms as the empty hand. 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: bridge init resolves its workspace the same way every other verb does, so 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 (configis per-operator,~/.saphan/config), so they keep exiting 0 from anywhere, including a fleet 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 somebody has to maintain. - A workspace found by walking up. Discovery resolved something; nothing was derived from an undeclared directory.
Exit 1 — an engine error, the same code every other refused invocation resolves to.