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:
git -C <repo> diff --name-only <base-sha>...<branch>— every tracked path the stream authored.git -C <worktree> status --short --untracked-files=all— the files that exist only in the worktree, which are the ones teardown can actually destroy.- 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:
| key | meaning |
|---|---|
verify.full.argv | The 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.report | How the engine reads the suite's result: go-test-json, junit-xml, or exitcode. Optional — see the floor below. |
verify.full.report_path | File 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.timeout | A 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:
certifyandverify-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--timeoutoncertify'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--timeoutflag — the config key is the only way to move it. Every run prints the budget it used and where the number came from (defaultorconfig:verify.full.timeout), green or not, and the record carries both asstep_budgetandstep_budget_source.
Set them one at a time; every other section of the file round-trips untouched:
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:
| key | meaning |
|---|---|
verify.provision.<alias>.repo | The 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>.source | The document on this host, absolute. Read at measurement time on the machine taking the measurement; never fetched. |
verify.provision.<alias>.dest | Where 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:
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:
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 emitsgo 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 byverify.full.report_path. The engine sums each outermost<testsuite tests="…">minus itsskipped="…"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 shapetests="0" errors="1"is a clean red when the suite process also exits non-zero: no testcase ran, so the record truthfully keepscount: 0, whilenamed_testscarries 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 positivetestsdeclaration remains an ordinary counted clean red. The record carries bothsuite_report_pathand the actual sortedsuite_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.certifywrites that as ameasurement-failedrecord; advisoryverify-unionreturns 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 prints0there. 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:
- Run
saphan config set verify.affected.argv "saphan,adapter,go-affected". - Run
saphan config set verify.affected.report go-test-json. - Optionally run
saphan config set verify.affected.timeout 5m. - Ask for the signal with
saphan verify-affected --repo /absolute/repo --base <base-ref>. The default head isHEAD.
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.
- Run
saphan machine probe --machine <id> --capability <name> --declared-path <absolute-path> --workhome <absolute-workhome> --probed-by <actor> --workspace <canonical-workspace>. - If it fails, read the two commands under
remedy. For an SSH machine the first is a completessh -F ~/.saphan/ssh/config <id> '<platform install command>'; the second is the complete re-measure invocation. - 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.ioon Debian), then re-measure;daemon-unreachable— on Linux, enable/start Docker and grant the remote user socket access; on Darwin, start Docker Desktop withopen -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.
- 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 andxcresultneed their own JUnit reporter/conversion step in the declared command. - Set
verify.full.report_pathbefore selecting the kind. Config-v1 writes one key per act, so this staging order never leaves ajunit-xmldeclaration without its required input. - Set
verify.full.reporttojunit-xml, then inspectsaphan 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**/*.xmlcan also catch unrelated source XML; every match is parsed, so one unrelated file fails the whole measurement. - Run
saphan certifyorsaphan verify-union. If it refuses with “matched no files”, fix the command or the path; do not replace the missing report withexitcodemerely 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:
What happens with no [verify.full] block at all:
-
In a Go repository (a
go.modbeside 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 assuite_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 baregootherwise — deliberately not PATH's firstgo: a verdict about the trunk must not change because a version manager was sourced in one shell and not another.certifyalready resolved it that way;verify-unionused to take PATH's, and now takes the same one. The record always carries the exactsuite_argvthat 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):
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.