Saphan StudioDocs
Getting started

Gates: where humans decide

One recorded human decision on one stream, the batch surface, and the verbs around it.

A gate is one recorded human decision on one stream. The gate verb is the only writer of gate rows, and its shape enforces the philosophy:

saphan gate <stream> --gate <which> --decision <what> [--ref <review-return>] [--note <detail>]
  • --gate is one of: stop1 · stop2 · merge · promotion · review — required, no default. teardown is not recorded by this verb. It is the same signed row, but it belongs on top of an archive this verb does not write, so --gate teardown refuses here and prints the equivalent saphan teardown command with your --actor, --note and --return arguments already folded in. See “The teardown decision has one road”, below.
  • --decision is one of: accept · conditions · reject · ready-to-merge · closed-without-merge — required, no default. A defaulted decision is unrepresentable at the surface.
  • The actor is the human deciding. The engine is never an actor.
  • There is no merged decision. Merged is only ever observed from a real merge commit — you cannot type a stream into the merged state.
  • A decision that would skip a gate in the required order is refused with a list of what is still owed, and nothing is written on refusal. saphan dry-run gate … runs the same checks and shows the row it would write, without writing it.
  • An owner-class gate (stop1, stop2, merge, promotion) is an owner act: it asks for a one-time six-character code typed back on your terminal. Where the workspace requires a person's signature on gates, the row is signed with your passkey in the console or, with --cli, with a security key at the terminal. A review gate records the reviewer's judgment and asks for none of these.

Recording a batch of owner decisions

When several streams are ready for a decision, one owner gesture can record them without paying the fleet-wide channel reconcile once per stream:

saphan dry-run gate batch /absolute/path/to/decisions.json --actor <you>
saphan gate batch /absolute/path/to/decisions.json --actor <you>

The file is one JSON object. Every decision is complete on its own; there is no shared or default gate, decision, or note:

{
  "decisions": [
    {
      "stream": "alpha",
      "gate": "stop2",
      "decision": "accept",
      "note": "review return alpha-17 accepted",
      "recommendation_ref": "alpha-review-17"
    },
    {
      "stream": "beta",
      "gate": "stop2",
      "decision": "conditions",
      "note": "land the two named review conditions"
    }
  ]
}

This surface is for owner-class gates only; a review item is refused. Every item requires a non-empty note, so the ground remains per stream and inside that row's signed bytes. The real run asks for the one-time code once, on a screen that lists every decision it covers. A deliberate correction behind a later gate additionally carries "out_of_order": "<why>" on that item.

The owner ceremony is:

  1. Build the file with one explicit object per stream. Do not reuse an old decision by omission: gate and decision are mandatory on every object.
  2. Rehearse it with saphan dry-run gate batch <file>. Read every per-stream preview or refused outcome; the rehearsal reads fresh agent activity once and records no gate.
  3. Correct refused inputs or, when the refusal is intentional and the named guard permits it, add that stream's explicit ground such as out_of_order.
  4. Run the same file with saphan gate batch <file>. The engine reconciles once, then records one separately signed row per admitted stream.
  5. Read the final named outcome. complete means every item recorded (or already stood in the record). partial means some items recorded and others refused or were not attempted; refused means none recorded. Both non-complete outcomes exit 2 and list every stream, so retry only the refused/not-attempted objects. Already-recorded items are safe but reported rather than silently duplicated.
  6. Verify any row independently with saphan gates --stream <stream>; an ok verdict is the per-row signature check.

The batch is deliberately not a collective decision and not a rollback transaction. A later refusal cannot erase earlier human acts: successful rows remain visible, and every item receives a named result.

Around the gate verb, three companions:

saphan fleet next --stream <slug> answers, in one read: the stream's current state, every gate it still owes, the legal next acts, and a ready-to-paste command for each. This is the "what do I do now" verb — it walks the same transition table the refusals use, so the affordances and the refusals can never disagree.

saphan merge <stream> executes the engine-led merge of a gated stream — it checks the preconditions (the gates actually recorded, the review lane satisfied when engaged), then materializes the live branch+trunk pair in a detached scratch worktree and runs a suite-free conflict probe. A conflict refuses and names the paths; a clean probe lands in the same serialized engine act and records what it did. Two concurrent merge acts wait in the engine and each probes the trunk it actually lands on. It is the one place mechanics touch the merge, and it runs only after the human's decision exists.

saphan verify-union <stream> is an advisory full-suite instrument. It records what the merged pair does under the deterministic suite and --status reports whether either recorded parent moved. That record is not a merge precondition: saphan merge writes the union verdict for the pair into its record but does not refuse on it; the live conflict probe above is what stops a merge. ⚠ verify-union refuses to measure until the suite's time limit is declared (saphan config set verify.full.timeout <duration>).

It runs the suite you declared (see Declaring the verification suite below), and it reports the count the same way the certification record does — so under the exitcode contract the line reads union clean-green — unavailable test(s) executed (report exitcode, suite config:verify.full.argv …) and the --json payload carries "count": "unavailable". It never prints 0 where nothing counted. A conflict prints no count at all: git could not combine the parents, so the suite never ran and there is no verdict about the code — the line says exactly that, and the --json record answers "count": "unavailable" and "named_tests": "unavailable" for the same reason, never 0 and never none.

saphan teardown <stream> archives the stream's channel artifacts (verified by checksums, into the stream's own work directory) and records the teardown gate — and refuses until the projection actually shows the stream merged and the worktree is clean of unaccounted files, with every return artifact's fate explicitly dispositioned (landed · landed:<path> · consumed · discarded-by-decision). It is an owner act. Recording the decision removes nothing: the worktree is removed by a second, separate act, saphan teardown <stream> --verify-archive, which re-verifies every archived byte first and, for a worktree on a remote machine, brings that machine's leftovers home before removing it. Both acts can be rehearsed with saphan dry-run teardown.

The teardown decision has one road

The teardown gate is recorded only by saphan teardown, because the row belongs on top of an archive: the channel family, the return manifest, .provision/ and the provisioned bytes are archived first, and every provisioned citation on the stream's run record is checked to still resolve to bytes that exist. saphan gate <stream> --gate teardown refuses, and prints the saphan teardown command that performs the same decision with its preconditions, with your --actor, --note and --return arguments folded in.

Gates are one of several acts that need the owner present. The full list, with a runbook for each newer one, is in Acts that need the owner present.

On this page