Saphan StudioDocs
Working with git

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 exist: 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.

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:

saphan gate <stream> --gate stop2  --decision accept          --actor <name>
saphan gate <stream> --gate merge  --decision ready-to-merge  --actor <name>
saphan merge <stream> --actor <name>

--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:

saphan fleet next --stream <stream>

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

saphan dry-run merge <stream> --actor <name>      # rehearsal
saphan merge <stream> --actor <name>              # the act

The rehearsal previews the ordinary preconditions and the composed commit message. It does not run the conflict probe and it does not land.

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, with no test suite: a conflict refuses and names the unmerged paths; a clean probe lands in the same serialized act. 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.

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 git 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. None of them accepts a URL or a remote name. There is no push path anywhere in the product, and the closing gate cannot be reached by adding a flag.

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.

  1. Merge — as above. The merge commit exists.

  2. The record shows mergedmerged enters through exactly one door, the observation:

    saphan fleet observe `<stream>`

    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.

  3. Only then, teardown:

    saphan teardown `<stream>`

    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. The archive happens before any removal, so the channel survives the tree.

⚠ 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.

On this page