Certifying the local trunk
What certification measures, what the pointer means, and the daemon that can own the ceremony.
Certifying the local trunk
saphan certify --repo <repository> --trunk <ref> resolves the local ref once to a full
SHA, runs the suite in a detached scratch worktree, and appends every attempt. The
--repo path must itself be the Git worktree root; a nested or non-repository directory is
refused instead of inheriting an ancestor repository through Git discovery. When --repo
is omitted, the current directory is certified — except at the workspace root, where the
workspace's one registered repository is used, and a workspace registering several is refused
with their names (pass --repo). --trunk defaults to develop. A
clean-green result advances the repository's local verified pointer; clean-red and
measurement-failed never do. The pointer means only that its named SHA, for its named
trunk ref, had a recorded green measurement. It is not a mutable alias for the latest
commit, and it says nothing about a remote.
The records live under
<workspace>/.saphan/trunk/<repository-key>/records/<sha>/; the pointer is
<workspace>/.saphan/trunk/<repository-key>/verified.json. Both are local. Attempt
records are append-only, while the pointer may move forward only to a descendant on the
same ref. --force is the explicit escape hatch for an inspected, intentional
non-descendant move; it does not permit changing the pointer's ref identity.
A pointer written by an older engine may have no trunk_ref. The engine withholds that
unidentified legacy projection from every verb rather than letting it dead-end them, and
the immutable attempt record it named stays on disk. It does not call the withheld pointer
absent: --status reports verified pointer: suppressed (legacy-projection), names the
file that is still on disk, and prints the repairing command. Run saphan certify --workspace <workspace> --repo <repository> --trunk <ref>: the next clean-green
measurement writes a new pointer with the ref identity. Do not delete the attempt records
to perform this migration.
A pointer file that exists and cannot be read at all — corrupt JSON, an empty file, a
foreign contract_version, a sha that is not a full SHA — is unreadable, and it is
never reported as absence either. --status projects it on both surfaces (it does not go
silent), names the file, names the reason and prints the act. certify and --bisect
refuse by name rather than overwriting a pointer nobody can read: a file that cannot say
where the pointer is coming FROM leaves the backwards/sideways guard nothing to compare
against. Re-run the printed act, which carries --force — the same escape hatch, for the
same reason — and the next clean-green measurement mints a fresh pointer over it.
Nothing is deleted: the immutable attempt records are the source of truth and
verified.json is only a projection over them.
--json carries every one of these distinctions in two fields, deliberately split so a
consumer can match the state exactly and still learn why:
pointer_state— one lowercase token, matchable with==:absent·suppressed·unreadable·present.pointer_state_reason— the machine qualifier, present only when the state has one:legacy-projection·corrupt-json·malformed·io-error.pointer_state_detail— the human sentence behind anunreadablepointer.pointer_pathandpointer_repair_command— populated only when there is a file and an act to name.
A machine caller reading --status --json therefore has to expect exactly two document
shapes, and the rule for which one arrives is: a status document when a status could be
constructed, a refusal envelope when it could not. The envelope carries refused: true
and class: certify-status-unavailable, and it appears only for the refusals that happen
before any pointer is read — --status combined with --bisect, a repository path git
would resolve upward to an ancestor repository, or a working directory the engine cannot
resolve. Every pointer state above, unreadable included, is a status document: it is a
measurement that came back, not a measurement that could not be taken. The two never
appear together, and neither is ever an empty stream.
A refusal is machine-readable on every form of the verb. With --json, certify writes
exactly one document to stdout, and refused says which kind it is — that one rule,
not a list of cases, is the contract:
refusedabsent or false — the verb ANSWERED. The document is that answer: the projection for--status, the run record for a certification, the completed walk for--bisect. It is still the answer when the news is bad, so an answer can arrive beside a non-zero exit; read the answer, not the code, for what was measured.refused: true— the verb produced NO ANSWER OF ITS OWN. The document is the refusal envelope:refused: true, aclassnaming which form could not answer (certify-status-unavailablefor the projection,certify-unavailablefor a measuring form) and themessagea human would have read on stderr. This holds for every fault raised inside the verb, whenever it was raised — including a--bisectwalk that starts, measures, and only then finds it has nothing to say. Such a walk hands back the envelope rather than a partialBisectResult, because a half-walk beside an error code reads to a machine as a completed clean walk. Nothing is lost: every attempt the walk made is in the append-only record.
Stdout never carries both documents and never carries a third shape. The one structural
exception is not certify's: the only stdout with no document at all belongs to an invocation
that never reached RunE, so there is nothing to write a document with. It exits 1 with
zero bytes on stdout and its reason on stderr. That is true of every verb in the engine, so
a --json consumer must treat empty stdout beside exit 1 as "the invocation was refused, read
stderr". Without --json the same sentence goes to stderr in plain text and stdout stays clean.
The envelope's refused: true marks "certify produced no answer of its own"; the exit code
says which KIND of thing stopped it, per the exit-code contract every command follows. The two never contradict each
other, and a refusal is exit 2 (ran to completion, verdict STOP) wherever it was decided:
- Raised by the verb, about its own arguments —
--statuswith--bisect, a--repopath that is not a repository root, a--repopath git would resolve upward to an ancestor repository. - Raised below the CLI, inside the certifier — a repository that declares no
[verify.full]suite and is no Go module (the refusal-by-name this verb advertises in capitals), a declared verification program that is absent on this host,--bisectwith no green baseline to walk from, a trunk ref git resolves to no commit, a verified pointer that belongs to a different trunk ref, a pointer move that is not a descendant without--force, a--bisectwalk that can name no culprit, a merge or first parent the suite could not measure at all. A suite that produces no countable verdict is exit 2 through a certification and exit 2 through--bisect, because both are decisions about what can be claimed, not faults of the engine.
Exit 1 means an engine fault — the machinery could not do the
job it was asked to do, as with an I/O failure or git that could not execute at all. It says
nothing about the trunk and nothing about the request. A pointer file that cannot be read is
NOT one of these: verified.json is a projection in your workspace, not the engine's own
state, so --status projects it as unreadable and the measuring forms refuse it by name
at exit 2 with the act that ends it. Which document stdout carries is settled by refused above, never by the exit
code: an exit-1 fault raised inside the verb hands back the envelope like every other
in-verb fault, and the invocation rejected before RunE hands back zero bytes. Do not read a
non-zero exit as "verified", whichever it is.
Automatic trunk certification in the daemon
The fleet supervisor that saphan-server runs can own the ordinary certification ceremony
after the operator has
named the repository once. The feature is deliberately off by default: an
engine upgrade must not start an unrequested, minutes-long suite. Enable it in
the workspace's existing <state-dir>/serverd.monitor.json; this is the same
file and the same reload path used by the supervisor's monitor, not a second
daemon configuration mechanism:
repository is required and must be absolute when enabled. trunk_ref
defaults to develop; debounce_ms defaults to two minutes;
retry_backoff_ms defaults to 30 seconds; and
max_measurement_attempts defaults to 3. Zero for any optional numeric field
selects its default. The suite still comes from the same [verify.full]
declaration described above (or the named Go-module default); the daemon does
not invent a second suite.
On each existing daemon tick, the supervisor reads debt through the same status
projection as saphan certify --status. A moved tip resets the debounce, so a
burst is measured once at its final SHA. The full suite runs asynchronously;
ticks, reload, stop, and manual triggers remain responsive. One certification
may run per repository: an in-process run makes the tick a no-op, while a lock
held by another certifier is checked again after the retry backoff rather than
queued or counted as a failed measurement.
A measurement-failed outcome retries with exponential backoff (30 seconds,
then 60 seconds with the defaults), up to the configured attempts per daemon
generation. A clean-red record is terminal for that SHA and is not measured
again until the trunk moves. Only clean-green advances the local pointer;
the daemon never gates, merges, pushes, or mutates remote Git state.
The daemon log is the audit trail. decided certification is owed names the
repository, ref, SHA, and debounce; starting names the live tip; completed
names clean-green, clean-red, or measurement-failed and the append-only
attempt ID. The daemon log itself is not timestamped; use that attempt ID to
read the immutable record's completed_at when the clock time matters. Retry
lines state the attempt limit and next backoff. saphan-server supervisor status prints
certification: idle, debouncing, in-flight, or unknown when the runtime
projection cannot be read. JSON also carries certification_enabled,
certification_in_flight, certification_pending_sha, certification_sha,
certification_last_outcome, certification_measurement_attempts, and
certification_next_retry_at when that projection is available. JSON always
carries certification_state; when the projection cannot be read, the state is
unknown and certification_error names the read failure.
To turn the loop off, set certify.enabled to false (or remove the certify
object) and run saphan-server supervisor reload. The reload cancels a pending debounce
and a context-aware suite already in flight. With the feature disabled, no
certification is decided or run; existing monitor rules continue unchanged.
Use the three forms as follows:
- Run
saphan certify --repo <repository> --trunk <ref>after the local trunk changes. A non-zero exit names either a red suite, an incomplete measurement, or a refusal; do not treat any of those as verified. - Run
saphan certify --status --repo <repository> --trunk <ref>to project the pointer and immutable attempts without running the suite. Exit zero means the current tip is still verified and its latest readable attempt at the tip is green; unreadable or foreign-version records are counted in the output instead of hiding the usable projection. - When the tip is red and a green pointer exists, run
saphan certify --bisect --repo <repository> --trunk <ref>. The engine measures existing first-parent merges and a prospective culprit's first parent. If it cannot isolate a merge, it refuses instead of naming one. - If
--bisectprints a revert order, inspect the named merge and the failing-test roster. Decide whether the local revert is appropriate; the engine does not decide or execute it for you. - If you accept the order, run the printed
git -C <repository> revert -m 1 <sha>by hand, inspect the resulting tree, and commit the revert. Resolve or abort any conflict using the repository's normal review procedure. - Run
saphan certify --repo <repository> --trunk <ref>again. The ceremony is complete only when this new local tip measuresclean-greenand the pointer advances. Nothing incertify,--status, or--bisectpushes.