An admission repository is protected by one pre-commit hook in its common Git directory. Because
linked worktrees share that directory, one installation protects the main checkout and every
present or future leg. The hook reads staged additions and renames, asks Git which ignore rule
matched each path, and refuses R43 when a root-level channel-family rule matched. The merge-time
R43 check remains in place as defence in depth.
enroll-machine.sh --repo ... installs the hook automatically after it creates or refreshes
the remote bare admission repository. For an admission repository that predates this mechanism,
the owner's one-time ceremony is:
Install or update saphan on that host so the binary includes the hook handler.
Measure the admission repository's absolute path with the machine registry and the host.
Read the emitted absolute pre-commit path, then prove a linked worktree resolves the same
common hook with git -C <linked-worktree> rev-parse --git-common-dir.
The installer refuses rather than replacing an existing, non-Saphan pre-commit hook. Combine or
relocate that hook explicitly and retry. A relative core.hooksPath is also refused: Git resolves
it from each hook's execution directory, so it cannot prove that one file reaches every linked
worktree. An absolute core.hooksPath is supported.
Remote worktrees fork from a shared clone whose origin is the machine's bare admission
mirror. Refreshing only the mirror leaves that clone stale; re-enrolling the machine is
also the wrong repair because enrollment reissues machine identity. machine refresh-repo
is the narrow provisioning act: it advances one base branch in the mirror, then fetches
that mirror into the shared clone. It never force-pushes, prunes, or touches the owner's
origin. This follows the fleet's 2026-08-12 §1.7 ruling: Mac↔runner transport inside the
fleet is provisioning; origin remains exclusively the owner's hand.
Returned work uses the sibling machine deliver-branch verb. It carries one explicitly
named local branch to the same registered mirror and then fetches that mirror into the
registered shared clone, so a later fork can resolve the branch on the far host. An absent
far branch is measured and created by the one named refspec; an existing divergent branch
is refused. The verb never force-pushes, prunes, deletes a branch, or pushes to origin.
If either half fails, the refusal names far admission mirror or far shared clone; success
is measured by reading both refs back.
saphan run performs this rung itself. The mechanism is not the verb's private
property: the remote-spawn path calls the same two
rules the verb calls — when a mirror may be refreshed (a strict ancestor of local base is
stale and may be fast-forwarded; anything else is divergent and is never touched), and
what may be forked from (a named ref, never a bare HEAD). So the runbook below is the
operator's path — for inspection, for a divergent mirror, and for repairing a machine
outside a dispatch. It is no longer a precondition you must satisfy by hand before a remote
spawn will run.
Runbook.
Measure the absolute path of the bare admission repo on the remote host. Register that
measured path once; do not derive it from the ssh hostname:
saphan machine set-repo-dir --workspace <workspace> --machine <machine-id> \ --repo-dir <absolute path on that machine>
If the workspace holds more than one repository, register the path per repo
instead — --repo <repo> — and read the next subsection first: without it the machine
can serve exactly one of them.
The canon of that path: it is ~/.saphan/repos/<basename> — repos/ PLURAL — and the
.git suffix is NOT part of the canon, which is why anything that resolves the repo has
to accept both <basename> and <basename>.git. Both shapes are on the fleet right now:
the enrolled hosts carry the suffixed one, while enroll-machine.sh composes a
suffix-less directory (basename "$REPO_URL" .git). This is also why the workdir
remediation saphan dispatch prints lists the canonical parent rather than globbing a
suffix, and reports measured: none when that parent holds nothing — a blank answer to
"where is the admission repo" would read as "nothing is wrong here".
From the canonical repository with its base branch checked out, measure without writing:
The command prints local, far-mirror, and far-clone origin/<base> SHAs. A stale result
exits non-zero, making this form suitable for automation.
If stale, run the same command without --dry-run. Read the second three-SHA line; all
three values must be equal. A non-fast-forward refuses instead of forcing.
Retry the remote dispatch. For a machine with repo_dir registered, rule R32 dials
the mirror and then acts on what it measured — you do not have to run steps 2-3
first:
Behind (the mirror is a strict ancestor of local base): R32 refreshes the mirror
itself and the spawn proceeds. It then reads the push back over the same dial. A
refresh that reports success and did not land, or a refresh that fails outright,
refuses the spawn by name — nothing is spawned onto a stale base, and the refusal
still prints the machine refresh-repo line so you can drive the repair by hand.
Divergent (the mirror carries commits local base does not): never auto-healed,
because a forced push there destroys far-side commits. It passes with a named note,
and repairing it is a human decision — steps 1-3 above, or the far side.
Equal or ahead: passes with a note.
Unmeasurable — no registered repo_dir, an unresolvable local repo or base branch,
an argv that will not compose, a failed dial, or a dial returning no legible SHA — is
named and passes. A machine with no registered repo_dir is not dialed at all and
retains the pre-registration behavior. A failed measurement is never silently
reinterpreted as evidence that the mirror is fresh, and it is never converted into a
refusal either.
Materializing the far worktree is the same rung in the one order that works: refresh the
mirror, fetch it into the shared clone, then worktree add from a named ref.
A far repo path belongs to the pair (machine, repo), not to the machine. A workspace
that holds two repositories — this fleet's holds saphan-studio-engine and
saphan-studio-bridge — cannot be served by a machine that knows only one path: every
stream owned by the other repository either cannot travel to that host at all, or travels
and forks the wrong mirror.
Why the engine refuses instead of picking. Both repositories name their trunk
develop. A leg handed the wrong mirror does not fail — git worktree add finds
refs/heads/develop, forks it, checks out the wrong files, and the whole dispatch composes
green. Nothing downstream can catch that, because nothing downstream knows which repository
the leg was supposed to be in. So the pair is either in the record or the dispatch does not
compose. There is no fallback to the unnamed repo_dir, none to "the only pair
registered", none to "the first one".
The repo name to pass is the workspace's own name for the repository — the same token
its orders carry in - repo: <repo>, which for a repository sitting directly under the
workspace root is just its directory name. An absolute path under the workspace normalises
to the same key, so either spelling works.
Ceremony (owner's hand, once per repository per machine).
Create the bare admission repository on the far host, if it is not there yet, and read
its absolute path back:
Expect machine set-repo-dir: <machine-id> repo <repo> registered (<path>). On the
first pair bound to a machine that still carries an unnamed repo_dir, the verb also
prints a note: — see step 4.
Repeat steps 1-2 for every repository that machine is to serve.
Read the note. The moment a machine binds its first pair, the unnamed repo_dir
stops answering: a stream whose repository has no pair on that machine now refuses by
name. That is deliberate (see "why the engine refuses" above), and it means step 3 is not
optional — a repository you skip is a repository that machine can no longer serve.
Verify what the record holds:
saphan machine list --workspace <workspace> --json
The machine's row carries repo_dirs with one entry per repository you bound. A machine
admitted before this mechanism carries no repo_dirs at all and keeps serving every
dispatch it serves today, unchanged — no migration command exists or is needed.
When a pair is missing, the dispatch refuses and names the repo, the machine, what that
machine does bind, and the command that fixes it. Read it as an instruction, not an error:
run: machine "runner-saphan" registers no far repo path for repo "saphan-studio-bridge"(it binds far paths for saphan-studio-engine=/home/runner/.saphan/repos/engine.git) — a farrepo path belongs to the pair (machine, repo), and this pair is not in the record. Refusingrather than reaching for another repo's mirror: the repositories in this workspace nametheir trunk alike, so the wrong mirror forks the wrong files, checks out the wrong tree andcomposes GREEN→ measure the bare repo's absolute path ON runner-saphan, then bind the pair: saphanmachine set-repo-dir --machine runner-saphan --repo saphan-studio-bridge --repo-dir<measured-absolute-path-on-runner-saphan> --workspace <workspace>
The sibling refusal fires when the stream carries no owning repo at all: the dispatch
cannot say which repository it belongs to, so it is not handed one, and the refusal names
- repo: in the order and saphan fleet observe <stream> --repo <owning-repo> as the two
ways to record it.
refresh-repo and deliver-branch choose the mirror by the same pair. Both verbs
already required --repo, and that one flag now names both halves of the same
repository: the local checkout the commits are read from, and the (machine, repo) pair
whose far mirror receives them. There is no second flag, and there is deliberately no way to
push repository A's commits into repository B's mirror.
The three outcomes are the pair's own, at these verbs too:
the pair is registered → that mirror, and the shared clone derived from it;
the machine binds no pairs → its unnamed repo_dir, exactly as before, for any
--repo value — this is what keeps a host admitted before the pair existed working with
no migration command;
the machine binds pairs but not this one → refusal by name, naming the repo, the
machine, what that machine does bind, and the set-repo-dir that fixes it. Nothing is
dialled: a refused invocation issues zero far commands.
The refusal matters more here than at dispatch, not less. These verbs push one branch
into the far mirror, and a branch the mirror does not have is created rather than
rejected — so the wrong mirror would silently grow another repository's refs, and for
develop (which both repositories use) the push lands on a real ref.
Provisioning a new host's mirror end to end. Run per repository, from the local
checkout, and read each output before the next step:
The far shared clone is derived from the pair's mirror name — mirror
<home>/.saphan/repos/<name>.git pairs with clone <worktrees-root>/<name> — so it moves
with the pair and needs no separate registration beyond set-worktrees-root. Two
repositories therefore get two clones, as long as their mirrors are named differently.
Delivering a returned branch. From the local checkout, run the dry-run first:
The output contains the local, far-mirror, and far-clone SHAs (or missing). A stale result
is non-zero and changes nothing. Re-run without --dry-run to perform the mirror push and
clone fetch. The command is idempotent when both far refs already equal the local branch;
it reports the measured equality and performs no mutation. The pair (or, on a machine that
binds none, repo_dir) and worktrees_root must already be registered; the verb refuses by
name, with those remedies, rather than deriving either path.