Docs · Architecture

Exit Codes

The sl run exit code is a claim about what the run proved, not a description of what broke. This page is the ladder — the five codes, the axis that orders them, and the error-class contract that lets a step or adapter keep the exit code honest. The mechanical table is the same one shiftlefter.runner.verdict owns; a drift guard keeps every copy in agreement.

If you are holding a nonzero exit right now: 1 means the app answered and at least one answer was wrong — start from the report’s failure evidence. 2 means nothing ran — fix the invocation, the config, or the spec’s symbols. 3 means ShiftLefter itself crashed — trust nothing in the run and file a bug. 4 means at least one scenario never got an answer — don’t touch product code until triage says why. The rest of this page is the reasoning behind those imperatives.

The table

Code Status Meaning
0 :passed Existential pass: ≥1 scenario selected, every selected scenario ran to a tolerated outcome, none failed or errored (pending tolerated only under :allow-pending? — the counts still confess).
1 :failed ≥1 scenario :failed — the app answered and the answer missed the expectation (including :sut-classed errors: the app answered with an error). Or pending under strict mode.
2 :planning-failed Invalid before execution: config/parse/discovery/binding errors, an un-runnable invocation, an empty selection. Pre-execution exclusive — nothing ran.
3 :crashed Runner crash — an uncaught throwable in ShiftLefter’s own code. Anywhere; wins over everything.
4 :degraded ≥1 scenario :error — either the harness’s own machinery broke (hook throw, capability provisioning, capture, harness; counted error) or a step’s adapter classified its failure :observation — no answer existed (counted unobserved). The verdict about the app is unavailable for those scenarios.

Precedence: 3 anywhere; 2 pre-execution exclusive; during execution worst wins, 4 > 1 > 0. Across setup.clj groups the suite aggregates worst-wins: 2 > 4 > 1 > 0.

One refinement at suite scope: the table’s “nothing ran” is true per group. Exit 2 is pre-execution exclusive per setup.clj group — some planning checks are decidable only at a group’s turn (on the setup pipeline, the registry to bind against exists only after that group’s :start), so a late group can plan-fail after earlier groups already ran. A suite-level 2 therefore means at least one group’s request was never runnable: the suite-as-asked was never fully specified, and no suite verdict exists — which is why 2 outranks 4 and 1 in the aggregate. The earlier groups’ evidence is real and sits in the report; gate on the exit code, not the report.

Aborts sit outside the table: an interrupted run exits by platform convention (Ctrl-C = 130) and carries no verdict — and on the warm path an interrupted client does not necessarily stop the run; the daemon may finish it server-side.

Two verdicts, three failures to measure

Exits 0 and 1 are verdicts about the app. Exits 2, 3 and 4 are three different ways the run failed to be a measurement, distinguished by whose side of the line gave way: 2 — your request (pre-flight, total: nothing ran); 3 — ShiftLefter’s own code (anywhere, total: trust nothing, file a bug); 4 — the machinery of observation between your spec and the app, mid-run (partial: those scenarios have no verdict).

Why 4 trumps 1: exit codes route the next action; the report carries the evidence. 4’s imperative — fix observation, rerun; this run is not the measurement you asked for — subsumes 1’s. Reporting 1 on a degraded run would claim a completeness the run lacks. The counts confess the failures either way — the summary line reads like 6 scenario(s): 3 passed, 2 failed, 1 unobserved.

Read the two verdicts precisely. 0 is an existential pass: the suite ran whole and the app met every expectation checked — it says nothing about expectations you didn’t write. 1 is the only code that indicts the product, and it doesn’t claim every test ran; it claims every selected scenario reached a verdict and at least one verdict was failed. 3 is the code you should never see: reaching it requires a ShiftLefter bug that escaped every structured handler, so its absence is the system working — it exists so that when it fires it cannot masquerade as anything else.

Hooks sit on the measurement side of the line, and one question settles every hook case: did this failure show the app being wrong? A hook never does — it sets and strikes the stage. If the stage collapses mid-scene, the review of that scene is void: not a bad play, no performance. The boundary is drawn at verdict time — a teardown throw after the verdict is earned does not touch the exit code.

Exit 2 is a proof, not a certificate

The symbol space (glossary, registries, config) is closed-world: ShiftLefter owns the whole universe, so exit 2 is sound and complete over it. The spec’s claims about the world (“this locator finds its element”) are open-world: the planner has a proof procedure for malformation only, so not refuted is not valid. Validation happens only by running, one run at a time — a green run is tested-and-not-falsified for this world-state, not proven. Consequence: a locator matching nothing at runtime is exit 1 / :assertion, never :observation and never a planning error — the spec-as-requirement was observed violated; glossary rot vs app regression is triage, downstream.

The asymmetry matters mid-debug. When exit 2 fires, your code is wrong — that half is proven. When it doesn’t fire, that is not the same as knowing your code is right; the planner can only refute, never certify. A green run is Popper, not Euclid.

So “the planner accepted my glossary” is never evidence the glossary is current. When an element-not-found shows up fresh, it is a fork to triage — did the glossary rot, or did the app regress? — not a verdict to relay. Both hypotheses are live at the failure site, which is exactly why the run reports the observation and leaves the attribution to you.

The three error classes and the witness relation

Every scenario has three parties: the expectation, the SUT, and the witness channel through which the SUT is observed (HTTP is only the most common one). A step or adapter may pre-classify the failure it throws with one recognized ex-data key, :error/class:

Class The witness… Scenario Exit
:assertion delivered an observation; it missed the expectation (wrong body, absent element, a late answer that arrived) :failed 1
:sut delivered the SUT answering with an error (500, stack-trace page, job state failed) — misbehavior observed is evidence :failed 1
:observation itself failed to deliver (refused, transport timeout, DNS, garbled transcript) — proves nothing about the app, cause-agnostic by design :error, counted unobserved 4

Absent (or unrecognized) = today’s behavior. The only behavioral cliff (1 vs 4) sits on the only decidable edge: did the witness answer, or not? An expected event that never occurs through a healthy witness is :assertion (the mailbox polled fine; the message never came); an expected response that never comes is :observation (silence is undecidable).

The class that tempts misuse is :observation on a timeout, so take the hard case: an endpoint that normally answers in 200ms times out at 10s, and you’re sure it’s the app’s bug. “Definitely a failure” is hindsight talking. At the timeout the run possesses ten seconds of silence, and that silence is observationally identical between would-have-answered-at-45s, deadlocked-forever, the network ate the request, and the NIC died. You “know” it’s the app only from the server’s vantage point, which the run never gets. Classifying silence as :observation is not a claim that nothing failed — see the next section — it is a refusal to convert silence into evidence.

Two give-backs keep most real slow-app cases out of :observation. A late answer that arrives is :assertion — lateness you observed is evidence. And polling-with-answers is :assertion: an await that times out after successful polls (a job stuck at 85%) had a witness that answered every time; the answers just never satisfied the expectation in time. The timeout record carries the structural tell — :last-value present means the witness was answering (assertion territory); :last-error means an attempt threw — and the two ride side by side when both happened. Precedence is ever-answered wins: a poll that never returned a non-nil value and saw at least one classified dark throw, at any attempt, inherits :observation on its timeout; any answer at any attempt keeps the timeout unclassified (the witness talked — assertion territory). The record’s :poll-counts digest — {:attempts n :answered n :threw n :observation-throws n}, four integers, never a history — shows which case you are reading.

The :assertion/:sut boundary, by contrast, carries zero behavioral stakes — both are :failed, exit 1; misfiling costs a rendering hint. :sut earns its place anyway: it routes debugging (start from the app’s own error — the 500 body, the server logs — instead of diffing expected against actual), and it gives the author holding a 500 who feels “this request FAILED” somewhere honest to put it that isn’t :observation. A 500 that turns out to be intended behavior doesn’t retroactively misclassify — :sut describes what was observed, an error-shaped answer; stale expectation vs regression is triage, same as the locator case. An adapter that never checks status degrades gracefully to :assertion; the low-effort path is always safe.

Exit 4 is not an acquittal — triage

The build is red, nonzero, blocking. What is withheld is only the indictment of the product, because the evidence never reached it. A transport timeout with pure silence is :observation even when a perf bug is suspected — silence proves silence; the product finding is made in triage with the environment ruled out, instead of guessed by an exit code.

Recurrence signature: the same scenario unobserved run after run while its siblings pass says suspect the SUT’s responsiveness, not the network.

Why the withholding is worth it: a false “exit 1: product broken” is most dangerous when it’s rare, because it gets believed. An agent handed a lying exit 1 does the worst possible thing — confidently fixes product code against phantom evidence. Exit 4 is the machine-legible stop sign: don’t touch the code; this run proved nothing about it; check the environment first.

The failure mode on the other side is rerun purgatory: mechanically treating every 4 as “rerun it” can park a chronically slow endpoint there indefinitely. The report names which scenarios went unobserved — read it before rerunning, and watch for the recurrence signature above.

Halt policy

{:runner {:halt-on-observation-failure …}} — config-only, default off; the value is a tri-state false | :group | :run, and true reads as :run. When on: after any scenario finalizes observation-class, no further scenario starts. Scenarios already in flight (under --max-parallel) run to completion and keep their verdicts; every unstarted scenario — the rest of the queue and the @serial tail — is skipped. The report says halted after scenario N (<name>): observation failure (console, HTML header line, and the --edn summary’s :halted {:after-scenario N :scenario "<name>"}). The exit code is 4 either way — the halt changes wall-clock and report noise, never the verdict. Off by default because skipping is a form of not-running what was asked and the environment can recover mid-run; CI is where it earns its keep.

The scope names your group topology. With setup.clj groups as partitions of one environment — the CI case, one deploy that never came up — :run is the honest setting: the halt is invocation-wide, groups not yet started are skipped entirely (their :start never runs; a dark environment gets no more setups), their aggregate rows say :run/status :skipped with no exit code, the invocation aggregate carries the same :halted record plus :group/label, and the invocation exits with the halted group’s 4. With groups as different environments — a dark channel in one says nothing about the others — :group halts only the group whose scenario went dark; its siblings run to completion. Status is the outcome axis (skipped = did not run); the :halted record is the cause axis — no new status word exists for it.

The wall-clock case for turning it on there: once the environment is gone, every remaining scenario pays its full timeout budget to learn nothing. The halt converts a long march of doomed scenarios into one observed failure and a short report.

Classifying your own adapters

Classification lives at the one place per transport that does I/O — the adapter’s catch site — never per step. One optional key, three values, absent = unchanged; declining to classify loses nothing. ShiftLefter’s own builtins ship pre-classified: the SMS family tags a failed provider query :observation (the provider is the witness); the browser family tags a WebDriver transport throw (:etaoin/http-ex — the request itself failed, no response existed) :observation, plus one driver answer, at one site: when the network refuses a navigation, the opens the browser to navigation step throws SUT unreachable: <code> for <url>, classified :observation — a closed set of Chromium net::ERR_* codes (connection refused/reset/timed out/closed, name not resolved, address unreachable, internet disconnected, the generic ERR_TIMED_OUT, ERR_EMPTY_RESPONSE) — and a page-load timeout there, the driver’s timeout answer to a server that accepted and never answered, throws SUT silent: navigation timed out for <url>, classified the same way (silence proves silence; the etaoin adapter’s :config {:page-load-timeout-ms N} sets how long the wait is — absent, Chrome’s default is five minutes). Every other driver answer — element-not-found included — stays product evidence. Known hole: a server that accepts and closes without a byte is nondeterministic in Chrome — sometimes it reports ERR_CONNECTION_RESET (classified, in the set), sometimes it returns its own error page, so the navigation succeeds and the scenario fails at the first element lookup as :assertion — no message-based signal exists for that second case. On a costume, a refused navigation first rebuilds the session and retries once; the classification lands on the retry’s throw.

The class is read from step throws only: a throw from a Before/After hook, from adapter provisioning (:start / create-capability) or from a capture fn is always the harness bucket (error), never unobserved, and never arms the halt gate — classify at the catch site whose throw reaches the step. (The bundled custom-capture example, examples/07-custom-capture-kind, tags nothing on its capture fn for exactly this reason.)

The contract in one sentence: opt-in pre-classification of failure kinds, authored once at the adapter’s catch site, fired at the failure site. It moves an honest subset of exit-1s to exit 4. It never promises to detect all infrastructure failure — an unclassified error keeps today’s behavior exactly, which is what makes adopting it zero-breakage.

The cost stays flat because classification lives where the I/O lives. If your adapter wraps its HTTP calls, the catch block that already turns a ConnectException into ex-info gains one key; steps change zero. Start by naming the witness — the channel you observe through, which is not always the system you’re testing (a provider API you poll is the witness; the app that should have sent the message is the SUT). Then apply the one razor: did the witness answer? Answered wrong → :assertion; answered with an error → :sut; didn’t answer → :observation. When a case feels too strange to classify, don’t — declining loses nothing, and the only misfiling that lies is calling a delivered answer :observation.

How this page stays true

This page is a mechanical projection of a live-maintained internal source, regenerated — never hand-edited — by the derivation pipeline and drift-guarded by the test suite: a hand edit here fails a test, and so does a page that no longer matches its source.