Saphan StudioDocs
Getting started

Returns and declaring the verification suite

The return manifest and the one direction it is not checked in; declaring which suite runs.

The return manifest, and the one direction it is not checked in

A stream declares its durable returns in its status file under - return: — paths only, one per line. Teardown archives from that manifest, so whatever the manifest omits is what teardown silently destroys.

The engine checks the manifest in one direction. At STOP-2 the returns-exist step reads every declared path and refuses if one of them is not on disk in the stream's worktree: a declared-but-absent return cannot pass review. Nothing checks the other direction — that every path the stream actually authored is declared — and a status file with no - return: field at all is an explicit clean pass. An understated manifest is therefore invisible to the engine: it looks exactly like a stream that produced no durable returns, and it fails at teardown, quietly, as missing evidence.

Until that second direction is mechanical, it is a human step, and it belongs to whoever accepts STOP-2. Before gating, compare the manifest against what the stream wrote:

  1. git -C <repo> diff --name-only <base-sha>...<branch> — every tracked path the stream authored.
  2. git -C <worktree> status --short --untracked-files=all — the files that exist only in the worktree, which are the ones teardown can actually destroy.
  3. Read - return: next to those two lists. A path you would be sorry to lose and do not see named there is a path teardown will not archive.

It is not automatic because the status file is gitignored and the authored set is knowable only from the live worktree plus the stream's base SHA — so the missing check is an engine step reading both (a returns-complete sibling to returns-exist), not something a test inside the repository can assert.

Declaring the verification suite

Which suite runs is your declaration, not the engine's assumption. Saphan certifies Go, Swift, Node, Java, Rust and anything else that can be run as a command and judged by what it returns. The declaration lives in ~/.saphan/config:

[verify.full]
argv        = "pytest,--junitxml=reports/junit.xml"
report_path = "reports/junit.xml"
report      = "junit-xml"
timeout     = "20m"
keymeaning
verify.full.argvThe whole command, comma-separated. Comma rather than space so an argument may legally contain a space: "xcodebuild,-scheme,My App,test" is four arguments, the third being My App.
verify.full.reportHow the engine reads the suite's result: go-test-json, junit-xml, or exitcode. Optional — see the floor below.
verify.full.report_pathFile or glob written by a junit-xml suite, relative to its detached working directory. ** spans directory levels. Required and consumed only for junit-xml; it may be staged beside the current kind while changing the declaration one key at a time. Absolute paths and .. escapes are refused.
verify.full.timeoutA Go duration ("20m"). Optional; without it the built-in 10 minutes applies. THREE verbs read this key, and they do not apply it to the same unit — see the paragraph directly below, because the number you set means something different for the third one.

What verify.full.timeout bounds, per verb. It began as one suite's budget and is now read by three, so the key's meaning is no longer a single sentence:

  • certify and verify-union — it bounds ONE ATTEMPT at the declared suite. The 10-minute floor is the measuring package's own default (unioniso.DefaultSuiteTimeout), not a flag default, so it applies identically to both. An explicit --timeout on certify's command line always wins.
  • verify-stream — it bounds ONE STEP, and that verb runs four of them (go build, go vet, go test, gofmt). Declaring "20m" therefore hands it four separate 20-minute ceilings, never a 5-minute quarter of one: this is the deliberately generous reading of your declaration, chosen so an operator who said they had time for a full measurement is never killed by an arithmetic they did not write. Its floor is its own constant (verifyStepWallClock, also 10 minutes), and it has no --timeout flag — the config key is the only way to move it. Every run prints the budget it used and where the number came from (default or config:verify.full.timeout), green or not, and the record carries both as step_budget and step_budget_source.

Set them one at a time; every other section of the file round-trips untouched:

saphan config set verify.full.argv "npm,test"
saphan config set verify.full.report exitcode
saphan config set verify.full.timeout 20m
saphan config show          # prints what is currently declared

A document your suite swears by that the repository must not carry

A certification and a union measurement both run in a detached scratch worktree checked out at a named SHA — a tree holding exactly what git holds and nothing else. That is the point of the detachment, and it is also a hole: a suite may legally compare the code against a document whose home is a different repository (the engine's own gateway tests read the signed client contract). The engine repository may not grow a copy of it and may not read a sibling path, so the document travels by declaration:

[verify.provision.contract]
repo   = "/Users/you/src/the-engine"
source = "/Users/you/src/the-contract/openapi.yaml"
dest   = ".provision/openapi.yaml"
keymeaning
verify.provision.<alias>.repoThe one repository this declaration belongs to, absolute. Compared against the repository path the measurement carries, through symlinks. A block naming another repository is not applicable to this measurement and is silently absent from it — it never materializes and never refuses.
verify.provision.<alias>.sourceThe document on this host, absolute. Read at measurement time on the machine taking the measurement; never fetched.
verify.provision.<alias>.destWhere the bytes land, relative to the measured tree's root. .. and absolute paths are refused where the declaration is read. Parent directories are created.

Set them one at a time, like every other key:

saphan config set verify.provision.contract.repo   /Users/you/src/the-engine
saphan config set verify.provision.contract.source /Users/you/src/the-contract/openapi.yaml
saphan config set verify.provision.contract.dest   .provision/openapi.yaml

Every materialized entry lands in the attempt record as dest + the sha256 of the bytes as placed, so a certification's inputs can be re-derived and a source that changed between two runs produces two visibly different records. If the source is missing, unreadable or zero bytes, the measurement is refused by name — never degraded into a verdict taken against a tree that is missing what it swears by.

repo IS MANDATORY, and a block without it is refused. It was not always: until 2026-09-02 an unscoped block was treated as global, and the first real declaration materialized the contract into every tree the engine measured — including the throwaway repositories the test fixtures build, whose suites assert their own tree is clean and so reported the gift as ?? .provision/. If you carry a pre-2026-09-02 block, the refusal names the one command that repairs it:

saphan config set verify.provision.<alias>.repo <absolute path of the repository>

What the measurement does about dirt. For the entries that DO match, the engine points the suite's git at an exclude file listing every dest it placed, so an inner suite that asserts a clean working tree sees the tree it asked for. The exclude lives in the scratch worktree's own git directory and is applied through the suite process's environment: it touches neither the measured tree's bytes nor your repository's shared .git/info/exclude, which is read by every worktree of that repository and would have silenced the path in your own checkout too.

The three report kinds. They are the parsing contract between your suite's result and what the record is allowed to say about it.

  • go-test-json — the suite emits go test -json's event stream. The engine counts the top-level tests that actually ran and names every failing one.
  • junit-xml — the suite writes one or more XML files selected by verify.full.report_path. The engine sums each outermost <testsuite tests="…"> minus its skipped="…" value; a nested suite is detail within that declaration and is not counted again. Matched files always sum independently, even when two files describe the same suite, so a glob that catches both an original run and its re-run counts both. It names every <testcase> carrying <failure> or <error> as <classname>.<name>, in report order. A suite-level <error> names the suite. The common initialization-failure shape tests="0" errors="1" is a clean red when the suite process also exits non-zero: no testcase ran, so the record truthfully keeps count: 0, while named_tests carries the suite name from the suite-level error. A zero-test record naming a failure without that non-zero process evidence is refused as contradictory. The same suite-level error with a positive tests declaration remains an ordinary counted clean red. The record carries both suite_report_path and the actual sorted suite_report_files, so the numbers say where they came from. A missing match, malformed XML, escaping symlink, or XML without a <testsuite> is never zero and never green-by-silence. certify writes that as a measurement-failed record; advisory verify-union returns an error and saves no record because it obtained no union measurement. Every attempt uses a fresh detached scratch tree, which bounds report files to that attempt instead of inheriting stale output from an earlier run. If XML names no failure but the process exits non-zero, the result is a crash/failed measurement, not a clean red with an empty failing-test list.
  • exitcode — the suite's process exit status is the entire verdict: zero is green, non-zero is red. There is no count and there are no test names, and the record says so in words: count: unavailable, named_tests: unavailable. It never prints 0 there. A zero where a count belongs is indistinguishable from a suite that ran nothing, and a number nobody measured printed beside a verdict is the exact failure this design refuses to commit. A red under this kind carries the real non-zero exit status as its evidence (suite_exit_code).

verify.affected is the FAST, ADVISORY tier. It normally runs the packages touched by --base...--head plus their full reverse-dependency closure; an unmatched path that may affect the build widens the run to the full module and the result labels that full cost. Known documentation-only paths map to no package instead of triggering that widening. The suite runs in a detached worktree materialized at the exact named head, never against uncommitted working-tree contents. The tier is useful while work is in flight, but it is not cheaper certification: it never licenses merge and never moves or satisfies the verified pointer. Only [verify.full] can do either. Every affected result names the tier, changed paths, no-package paths, changed packages, selected packages, omitted packages, whether it widened to the full module, and the measured closure contribution, so a reader can see exactly what was and was not run.

The engine does not implement ecosystem graphs. [verify.affected].argv declares a proc adapter. The engine writes a versioned JSON manifest containing the repository, base, head, and changed paths, then sets SAPHAN_AFFECTED_MANIFEST to that file. The adapter must write its versioned scope record to the path in SAPHAN_AFFECTED_SCOPE_OUT before running its suite. A junit-xml adapter writes its report beneath the fresh directory named by SAPHAN_AFFECTED_REPORT_DIR; report_path is relative to that directory, so an old report in the working tree can never be reused. A missing scope or report is an error, never green. This file protocol avoids command line length and escaping limits and lets Swift, Gradle, Node, or any later stack own its actual dependency graph without teaching the engine that stack.

The bundled Go adapter uses go list -json ./..., maps changed paths to packages, and walks the full reverse graph over Imports, TestImports, and XTestImports. If a changed repository-level path cannot be mapped safely, it widens to all packages. Configure it as:

  1. Run saphan config set verify.affected.argv "saphan,adapter,go-affected".
  2. Run saphan config set verify.affected.report go-test-json.
  3. Optionally run saphan config set verify.affected.timeout 5m.
  4. Ask for the signal with saphan verify-affected --repo /absolute/repo --base <base-ref>. The default head is HEAD.

The affected tier owns the same closed keys as the full tier: argv, report, report_path, and timeout; it uses the same three report kinds. If no affected adapter is declared for a stack, the result is unavailable, never green by silence.

verify.surface remains reserved and unimplemented. Writing it is refused by name. The same holds for a scope this engine does not know ([verify.bogus]) and for a scope-less [verify].

Inside [verify.full] the keys are a closed set too: argv, report, report_path, timeout, and nothing else. A misspelling — argvv = "npm,test" — is refused by name, because a dropped key looks exactly like a key that worked. And if the block is there but names no argv, the refusal says so: it will not tell you the file declares no [verify.full] while you are looking straight at one.

If you declare an argv but no report, the engine applies the exitcode floor. It does not assume your command speaks Go's JSON event format, because reading a stream that is not that format finds zero events and would report zero tests.

Toolchain readiness ceremony. Before certify takes its lock or creates a detached worktree, it resolves the first token of a declared [verify.full].argv on the local host. An absent program refuses as verification-toolchain-absent and names the token, the machine (local), the matching capability-catalog row when one exists, and that row's platform-specific install command. No scratch worktree is left behind. This check makes no claim about a far execution host; remote suite preflight is a separate transport concern.

For an admitted machine, measure the toolchain explicitly after installation. The probe never installs anything; its output and signed capability row carry the same remedy and the re-measure command:

The catalog's Machines column preserves the historical m0..m3 topology; it is descriptive metadata, not a runtime allow-list. For both a legacy alias and a real fleet ID, applicability is governed by the requested capability being in the closed catalog and by the machine's declared absolute program path. The probe then proves or refutes that declaration; a machine ID alone never makes a toolchain present or absent. Platform-specific remedies are selected only from the machine row's measured confinement facts. If that row does not yet prove a platform, the remedy says NO INSTALL COMMAND and asks you to measure the machine first; it never guesses Debian from an ID or host name.

  1. Run saphan machine probe --machine &lt;id> --capability &lt;name> --declared-path &lt;absolute-path> --workhome &lt;absolute-workhome> --probed-by &lt;actor> --workspace &lt;canonical-workspace>.
  2. If it fails, read the two commands under remedy. For an SSH machine the first is a complete ssh -F ~/.saphan/ssh/config <id> '<platform install command>'; the second is the complete re-measure invocation.
  3. Execute the install command only after reviewing it, then execute the re-measure line. A probe itself never runs a package manager, enables a service, or pulls an image.

Docker environment and saphan-probe ceremony. Container-class dispatch requires a fresh docker-oci capability before spawn, not merely a docker executable. The probe runs the repository's tiny scratch image (images/probe/Dockerfile) with --pull=never and accepts only its fixed SAPHAN-PROBE-RAN / SAPHAN-PROBE-OK markers plus positive VM facts. That row means only “this machine can run the pinned saphan-probe image and the image reports positive VM facts.” It does not measure nested user-namespace confinement; the separate sandbox-userns capability row owns that check. The outcomes remain distinct in the row:

  • binary-absent — install the runtime (docker.io on Debian), then re-measure;
  • daemon-unreachable — on Linux, enable/start Docker and grant the remote user socket access; on Darwin, start Docker Desktop with open -a Docker; then re-measure;
  • probe-image-absent — load or pull the exact pinned digest, then re-measure.

Publishing the image is an operator/release act. From the repository root, build it with docker build --file images/probe/Dockerfile --tag ghcr.io/saphan-studio/saphan-probe:candidate .; the final . is the required repository-root build context because the Dockerfile copies images/probe/main.go. Publish ghcr.io/saphan-studio/saphan-probe and replace the deliberate zero digest in the capability catalog with the registry-reported digest. Until that pin exists, machine probe --capability docker-oci refuses as docker-probe-image-unpinned and records no measurement. A tag is never accepted, and the probe never pulls: preload the exact digest before probing. A container dispatch whose machine has no fresh base docker-oci row refuses as container-docker-capability-absent and prints the install plus re-measure route.

Declaring a file-based JUnit suite is a four-step ceremony. The command itself must write the file the path selects; choosing junit-xml does not add framework flags or a reporter on your behalf.

  1. Configure the suite command. For example, pytest can write JUnit directly with pytest,--junitxml=reports/junit.xml; Gradle and Maven normally write beneath **/build/test-results/ and **/target/surefire-reports/; jest/mocha and xcresult need their own JUnit reporter/conversion step in the declared command.
  2. Set verify.full.report_path before selecting the kind. Config-v1 writes one key per act, so this staging order never leaves a junit-xml declaration without its required input.
  3. Set verify.full.report to junit-xml, then inspect saphan config show. Confirm that the glob is relative to the repository root and matches only the files your command writes. A broad glob such as **/*.xml can also catch unrelated source XML; every match is parsed, so one unrelated file fails the whole measurement.
  4. Run saphan certify or saphan verify-union. If it refuses with “matched no files”, fix the command or the path; do not replace the missing report with exitcode merely to obtain a green result.

To leave junit-xml, change verify.full.report to the new kind first, then clear verify.full.report_path. Clearing the path while junit-xml is still selected is refused, because it would leave the active file-based contract without its required input.

One concrete pytest declaration is:

saphan config set verify.full.argv "pytest,--junitxml=reports/junit.xml"
saphan config set verify.full.report_path "reports/junit.xml"
saphan config set verify.full.report junit-xml
saphan config show

What happens with no [verify.full] block at all:

  • In a Go repository (a go.mod beside the repository root): the engine runs Go's own module suite — go test -count=1 -json ./... — exactly as before this key existed, and the record NAMES that fallback as suite_source: default-go. The default is allowed to happen; it is not allowed to be invisible. The program is the toolchain the engine installs, /usr/local/go/bin/go, whenever that exists, and a bare go otherwise — deliberately not PATH's first go: a verdict about the trunk must not change because a version manager was sourced in one shell and not another. certify already resolved it that way; verify-union used to take PATH's, and now takes the same one. The record always carries the exact suite_argv that ran, so you can see which it was.

  • In any other repository: certification refuses, by name, before it measures anything. This is deliberate. The alternative is running Go's test command in a Swift package, getting a toolchain error, and reporting a verdict about it. The exact text you will see (wrapped here; the engine prints the first paragraph as one line):

    saphan: trunkcert: refusing to certify /path/to/app: verify: /path/to/app carries no
    go.mod and config-v1 declares no [verify.full] suite, so there is NO suite to run here —
    refusing to measure rather than running Go's own test command in a repository it knows
    nothing about and reporting a verdict about the result
    (detected: a swift project (Package.swift)). Declare the suite, then re-run:
      saphan config set verify.full.argv "swift,test"
      saphan config set verify.full.report exitcode
    `saphan init` proposes this block for a detected stack, and `saphan config show` prints
    what is currently declared.

    The refusal names the stack it recognised and proposes that stack's own command. Nothing is written and no attempt record is appended: a measurement that never happened leaves no trace.

saphan init proposes the block for you. It looks for one marker file in the workspace root — in this order: go.mod, package.json, Cargo.toml, pyproject.toml, Package.swift, pom.xml, build.gradle(.kts) — explains the trade, shows the exact two keys it would write, and asks, defaulting to no. Without a terminal it writes nothing unless you pass --yes. If it recognises nothing it proposes nothing and says so: a made-up suite is worse than none. Detection is first-match in that fixed order, so a Go service that also carries a package.json for its web assets resolves to Go, the same way on every machine.

One declaration per operator, today. [verify.full] lives in ~/.saphan/config, so it applies to every repository this operator certifies. If you certify repositories of two different stacks from one account, declare the one you certify and pass the other's suite explicitly, or keep separate config homes; a per-repository override is not in this version.

On this page