Skip to main content
A release test ends in exactly one verdict: PASS, FAIL or INCONCLUSIVE. The verdict is computed by a small, pure function from the five check results and one optional reason. No model, heuristic or person decides it, and the same inputs always produce the same verdict.

The three verdicts

There is no fourth verdict for errors. A failure inside Selo becomes INCONCLUSIVE with the reason INTERNAL_ERROR.

The reducer

The rule runs in this order and stops at the first match:
1

Any proven blocking FAIL gives FAIL

If any blocking check has status FAIL and at least one evidence id, the verdict is FAIL. This wins over everything else, including a reason recorded during the run.
2

A recorded reason gives INCONCLUSIVE

Otherwise, if the run recorded an inconclusive reason (a timeout, an unresolved payment, a lapsed authorization and so on), the verdict is INCONCLUSIVE with that reason.
3

Missing or unproven results give INCONCLUSIVE

Otherwise, if any check is missing, or any blocking check is INCONCLUSIVE or has no evidence, the verdict is INCONCLUSIVE with EVIDENCE_INCOMPLETE.
4

Everything else is PASS

Otherwise the verdict is PASS. Blocking WARN results with evidence do not prevent it; they are listed in warnings.
This is the function, as it ships in packages/core/src/verdict.ts:
packages/core/src/verdict.ts
  • indexById rejects a result set that contains the same check twice.
  • findProvenFailures returns the blocking checks with status FAIL and a non-empty evidence list.
  • hasIncompleteEvidence is true when any of the five checks is missing, or a blocking check is INCONCLUSIVE or has an empty evidence list.
  • collectWarnings returns "<check id>: <summary>" for every WARN, and for any non-blocking FAIL or INCONCLUSIVE.
The result does not depend on the order of the checks; the test suite runs every permutation of a five-check set.

Decision table

Every check Selo runs today is blocking, so in practice every result counts.

How the recorded reason is chosen

While a job runs, a step that cannot reach a definite answer records an inconclusive reason. The run keeps the first reason it records, with one exception: PAYMENT_UNRESOLVED replaces any earlier reason, because a payment with an unknown outcome is the most important thing to report. When the job’s 60-second wall clock has expired by the time the run concludes and no reason was recorded, the reason is TARGET_TIMEOUT.

Inconclusive reasons

Why unknown is never PASS

A release gate is only useful if a PASS means something. Selo treats every gap as a reason not to pass:
  • A check that did not run is not a pass. A missing result makes the verdict INCONCLUSIVE.
  • A pass without evidence is not a pass. Every blocking result must point at evidence that Selo recorded during the run.
  • A failure without evidence is not a failure either. Selo blocks on it, but reports INCONCLUSIVE rather than claiming a defect it cannot show.
  • Infrastructure trouble is not your fault. A timeout, an unreachable catalog or an unresolved payment yields INCONCLUSIVE, never FAIL, because Selo cannot tell whether your endpoint or the network was at fault.
Both FAIL and INCONCLUSIVE block the release. The difference tells you where to look: FAIL points at your endpoint, INCONCLUSIVE at the conditions of the test.

Internal errors

If something unexpected goes wrong inside Selo during a run, Selo does not drop the job:
  1. A payment that was open is held UNRESOLVED, so its amount still counts against the budget.
  2. Checks that had started report INTERNAL_ERROR; checks that had not started report NOT_RUN.
  3. The verdict is reduced with the reason INTERNAL_ERROR. A FAIL that was already proven still stands.
  4. The report is written as usual.
When Selo restarts, a sweep concludes every job that was stranded mid-run the same way.

From verdict to gate

The selo CLI does not take the API’s verdict on trust. When selo test receives a report, it decides the gate in this order:
1

Re-derive the verdict

The CLI runs the same reducer on the report’s own checks and inconclusiveReason. If the result differs from the reported verdict, or the checks cannot be read (for example a check appears twice), it blocks with exit 2.
2

Block a FAIL

A FAIL blocks with exit 1, naming every blocking check that failed.
3

Block an INCONCLUSIVE

An INCONCLUSIVE blocks with exit 2, naming the reason.
4

Require a fully evidenced PASS

A PASS blocks with exit 2 unless the report has a result for all five checks, an inbound settlement transaction id, at least one downstream payment transaction id and a report link.
5

Allow

Only then is the gate ALLOW, with exit 0.
selo verify checks a saved attestation offline and exits with the attested verdict (0 for PASS, 1 for FAIL, 2 for INCONCLUSIVE), or 2 when the attestation cannot be verified. See the CLI overview for every case that leads to each exit code, and Gate deploys in GitHub Actions to wire the exit code into a workflow.

Two verdicts from Algorand Mainnet

On 26 October 2026, the demo merchant at https://demo.useselo.xyz/v1/orders was tested twice at the same URL.
The proven FAIL on response_contract wins over the inconclusive discovery check, so the verdict is FAIL with inconclusiveReason: null. The gate answered BLOCK, reason FAIL: response_contract, exit 1.
See Proof on Algorand Mainnet for the transactions and the GitHub Actions runs.

The five release checks

Every code each check can report.

Signed attestations

The verdict, signed with Ed25519 and verifiable offline.

CLI overview

Install, environment and the full exit code reference.

Recover an interrupted run

What to do when the gate exits 2 because a payment is unconfirmed.