Merging
The gate sequence, why a merge is a human act, and the two-stage close that follows it.
Merging
The gate sequence
Six gates take decisions: stop1, stop2, review, merge, promotion, teardown. Five decisions are writable: accept, conditions, reject, ready-to-merge, closed-without-merge. Which decisions each gate accepts is a closed list, and a pair outside it is refused by name:
| gate | decisions it accepts |
|---|---|
| stop1, stop2 | accept · conditions · reject · closed-without-merge |
| review, promotion | accept · conditions · reject |
| merge | ready-to-merge |
| teardown | accept — and only through saphan teardown |
stop1, stop2, merge and promotion are owner decisions: recording one asks for a one-time six-character code typed back at the terminal (see owner presence). A rehearsal asks for nothing.
⚠ merged is not on that list, and its absence is the design. It is not a decision anyone
may record — it is a git observation. There is no writable path to it anywhere in the
product.
The ordinary path, once a stream has returned its finished status:
The merge also stands directly on a stop-2 accept, so the common case is one act:
It records the stop-2 accept and then merges, each half through its own verb's preconditions,
behind one confirmation that names both acts. A refused merge leaves the gate row standing.
Use saphan gate and saphan merge for everything else — conditions, reject, review gates.
--gate merge requires the stream's latest stop-2 decision to be accept, re-read from the
record rather than trusted from the caller. When a review lane is engaged, --gate review
records the reviewer's judgement, and the merge refuses while that judgement is on record with no
decision answering it.
⛔ A gate is never skipped. Every refusal in this sequence names the state the stream is actually in and the state the act needed.
If you would rather be told than remember:
One read gives the stream's resolved state, every gate it still owes forward to a terminal, each act the record makes legal from here with a ready command, and each act it does not with that act's own refusal text.
The merge itself
The rehearsal previews the preconditions and the composed commit message, and runs the same conflict probe in its own detached scratch tree, which it removes again. A pair git cannot combine is refused in the rehearsal too, naming the conflicted paths. It lands nothing and writes nothing.
The act verifies its preconditions read-only, before git is touched: the stream stands at a
gated accept or at ready-to-merge, the merge gate is legal, the branch exists, the base is
checked out and clean, the work is not already merged, and the base has not diverged from its
remote. Any failure is a named refusal with a remediation line, and nothing is landed. After
those checks it materializes the current branch-and-trunk pair in a detached scratch tree and
merges there: a conflict refuses and names the unmerged paths. When the repository declares
a build command (saphan workspace repo set-build <repo> "<command>"), that command then runs on
the merged tree — a compile and static check, never a test suite — and a tree that does not build
is refused (merged-tree-does-not-build) with the trunk unmoved; a build that cannot run to an
answer refuses too. With no build declared, the merge says so and no build check runs. A clean
probe lands in the same serialized act, pinned to the trunk commit that was checked. The commit message is composed from the record and
reaches git byte-exact, blank lines included, and the resulting commit's trailers print as
proof-of-done.
The merge lands on the branch the order names in - lands-on:, or else on - base:.
--onto <branch> lands one act on your trunk instead — for a continuation round whose recorded base is
its predecessor's branch; it opens no door, and every gate and probe still runs. Where your
workspace requires a person's signature on gate rows, the merge row is signed with a passkey in
the console, or with a security key on the terminal with --cli.
A merge is a human act
This is worth stating plainly for anyone who has to answer for it later.
The product holds one mutating merge command. Its arguments are fixed inside the product —
there is no caller-shaped git — and it is reachable only from the verb a human invokes, after the
preconditions verified. --actor names that human, and the invocation is the act: the engine
executes a decision, it never makes one.
Everything else the product does to your local repository is either a read, from a small
closed allowlist of subcommands that are read-only in every form, or one of a short list of
individually named operations whose arguments are likewise fixed: the merge above, importing a
branch from a local bundle file, provisioning a local landing repository, creating and removing
working trees, and the detached probes. Nothing in this path pushes to, fetches from or
mutates your origin, and the closing gate cannot be reached by adding a flag.
Two things do leave this machine, and both are named: delivering a base or a branch to your own machines' mirrors over SSH (Delivering a branch), and — only when you have configured a forge — delivering a signed merge decision to it (Pull requests and forge integrations). Neither ever force-pushes or rewrites history.
The two-stage close
Merging is not the end. Teardown is a second, separate decision, and it stands on a fact the product read rather than a fact it asserted.
-
Merge — as above. The merge commit exists.
-
The record shows merged —
mergedenters through exactly one door, the observation:This is why stage two exists as a stage. The product will not write "merged" from having performed a merge; it writes it from having looked.
-
Only then, teardown:
It archives the stream's channel documents into that stream's channel archive, verified byte-exact against a generated checksum manifest, and then records the teardown gate's accept. The teardown gate admits only
accept— a teardown you decline is a refusal, loud and unrecorded, never a decision row.saphan gate --gate teardownrecords nothing and points you here. The archive happens before any removal, so the channel survives the tree.saphan teardown <stream> --verify-archivethen removes the worktree itself, once the gate is recorded and every archived byte re-verifies — over the same SSH connection for a worktree on a remote machine, after bringing its remaining channel files home into the archive. Rehearse it withsaphan dry-run teardown <stream> --verify-archive, which names the directory it would remove and removes nothing.
⚠ Both terminals admit a teardown: a stream closed without a merge has a live worktree with no merge ahead of it, and it needs a legal end too.
Verifying what came home
What to measure before anyone gates it — including the branch merged onto its trunk — and the three things a green result has to name.
Pull requests and forge integrations
What the product reads from your forge, the one write it makes after a signed human decision, how that merge is gated, and which parts are not available on this release.