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 at the canonical workspace root, the verb names the resolved <workspace>/saphan-studio-engine target before it reports or measures anything. 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 &lt;workspace> --repo &lt;repository> --trunk &lt;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 §3.3 contract. 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. That last one is why the same condition no longer reports two different codes: 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 stays what it has always meant: 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 serverd

serverd 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 SIGHUP reload path used by the daemon 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); serverd does not invent a second suite.

On each existing daemon tick, serverd 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 serverd 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 serverd 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 &lt;repository> --trunk &lt;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