Saphan StudioDocs
Getting started

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 an unreadable pointer.
  • pointer_path and pointer_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:

  • refused absent 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, a class naming which form could not answer (certify-status-unavailable for the projection, certify-unavailable for a measuring form) and the message a human would have read on stderr. This holds for every fault raised inside the verb, whenever it was raised — including a --bisect walk that starts, measures, and only then finds it has nothing to say. Such a walk hands back the envelope rather than a partial BisectResult, 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 — --status with --bisect, a --repo path that is not a repository root, a --repo path 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, --bisect with 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 --bisect walk 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:

{
  "poll_interval_ms": 30000,
  "rules": [],
  "certify": {
    "enabled": true,
    "repository": "/absolute/path/to/repository",
    "trunk_ref": "develop",
    "debounce_ms": 120000,
    "retry_backoff_ms": 30000,
    "max_measurement_attempts": 3
  }
}

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:

  1. 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.
  2. 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.
  3. 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.
  4. If --bisect prints 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.
  5. 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.
  6. Run saphan certify --repo <repository> --trunk <ref> again. The ceremony is complete only when this new local tip measures clean-green and the pointer advances. Nothing in certify, --status, or --bisect pushes.

On this page