Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ADR-0002 — Multi-engine core: factory/session seam, step-kind routing, batching

Status: Accepted · Date: 2026-07-28 (amended 2026-09-01 — the core’s entry grammar is a named closed set; see the Amendment below, and its 2026-09-10 correction)

Context

Requirement: all tests are Gherkin; the parser dispatches to pluggable engines — API (hurl) now, a future non-hurl engine possible later behind the same seam — a factory/session seam (multiple engine implementations behind one trait, a step’s kind-prefix routes it to its engine, one shared variable scope). Balanced-architecture stance: deliberate seams for a future engine, no gold-plating. Ecosystem survey: probe-rs’s ProbeFactory/DebugProbe split is the closest production analog; sqlx registers compiled-in drivers explicitly; dispatch cost at batch granularity is noise, so dyn vs enum is decided on coupling, not performance (enum_dispatch would couple core to every engine crate — wrong direction).

Decision

Two traits in proef-core; engines implement both; the CLI assembles the registry.

#![allow(unused)]
fn main() {
pub trait EngineFactory: Send + Sync {
    fn id(&self) -> &'static str;
    fn step_kinds(&self) -> &'static [StepKindSpec];  // pack namespace + schema fragment
    fn doctor(&self) -> Vec<DoctorCheck>;
    fn open(&self, ctx: &ScenarioCtx) -> Result<Box<dyn EngineSession>, EngineError>;
}
pub trait EngineSession: Send {
    fn run_batch(&mut self, batch: &StepBatch, world: &mut World,
                 events: &EventSink, cancel: &CancellationToken) -> BatchResult;
    fn finish(&mut self) -> Result<(), EngineError>;
}
}

Routing: a macro step’s kind names its engine (http: → engine-hurl; other kind prefixes reserved for a future non-hurl engine). A lowered scenario is an ordered heterogeneous step list; the core dispatches contiguous same-engine batches in order. The World is the interop bus between batches and engines. Sessions are per-scenario, opened lazily, torn down in finish (+ Drop backstop); engines may hold sessions concurrently within a scenario. Registry: Vec<Box<dyn EngineFactory>> in proef-cli, engines optionally behind cargo features (one feature per engine). Lifecycle is enforced by ownership shape (only a session runs batches), not typestate generics (which would break dyn).

Consequences

Adding an engine = one crate + one registry line; pack schema and doctor extend via step_kinds()/doctor() without core edits — the acceptance test: a future non-hurl engine lands with zero proef-core diff. Core stays free of engine-specific types. Costs accepted: Box<dyn> indirection (irrelevant at batch granularity); two traits instead of one (justified: lifecycle safety + capability discovery). Engines own their artifacts (hurl files / screenshots / HAR).

Alternatives considered

Single Engine trait with runtime lifecycle state (v3 draft) — weaker lifecycle guarantees; enum dispatch — inverts the dependency direction; dynamic loading (dlopen/ WASM) — rejected as over-architecture, compiled-in covers every stated future; typestate generics — fights dyn, ownership shape gives most of the safety.

Errata

2026-07-28 (M1/M5): The routing example above names the API step kind http:; ADR-0004’s examples and TECH-SPEC §6’s normative pack schema use hurl: (the raw-block key doubles as the routing kind). The implementation follows the tech spec: the step kind and the engine id are hurl, so “a step’s kind names its engine” holds verbatim. Read http: in the Decision above as hurl:. Other kind prefixes remain reserved for a future non-hurl engine as written.

Amendment — the core’s entry grammar is a named closed set

2026-09-01 · Accepted. “Core stays free of engine-specific types” is true and stays true. “Core stays free of engine-specific syntax” was never true, and the worklist carried the gap for two rounds without resolving it. This amendment states the real boundary and makes it enforceable.

Why the core knows any hurl at all

The core performs text surgery on entries: bake_entry_options splices an [Options] block into each entry after its header block, and an expect: macro merges asserts into the previous request entry (ADR-0004). Both operations have to find an entry boundary in text the engine will later parse. That is structural, not incidental — the surgery is what the pack format is built on — so a boundary recogniser has to live somewhere, and pushing it behind the seam would move the literals without making the algorithm engine-independent.

The measurement

Not the “~290 lines, all in lower.rs” the worklist recorded — that figure counted #[cfg(test)] fixtures, where a core test exercising the pipeline necessarily writes some engine’s payload. The vocabulary is thirteen distinct literals across four files:

GroupTokensWhere
written — the core generates this hurl[Options], [Asserts], HTTP *, variable:, retry:, retry-interval:, delay:lower.rs
recognised — read to find an entry boundary``` (body fence), HTTP / HTTP / HTTP/lower.rs, emit.rs, pack/validate.rs
quoted — a hurl snippet shown to an authorGET ${url:base}/PATH, HTTP 200bind.rs

The four boundary recognisers (is_method_line, is_section_header, is_response_line, is_header_line) are already one canonical pub(crate) set shared by three of those files. That half is done.

The third group is the one this measurement nearly missed, and it is worth naming why. bind.rs renders a did-you-mean help string for an author whose sentence bound no macro, and that string contains a small hurl example. It generates nothing and parses nothing, but it is engine syntax living in the core, and it drifts like any other copy. The guard’s first version could not see it — the literal spans lines, and a per-line scan discards a run that never closes — while this amendment claimed the set was closed. Multi-line literals are where a larger piece of engine syntax would naturally be written, so the blind spot sat exactly where the risk is highest. The guard now lexes whole files.

The same row cost a second correction (2026-09-02). Lexing whole files surfaced HTTP 200; the GET ${url:base}/PATH line directly above it in the same literal stayed invisible for another round, because the guard classified four shapes — fence, response line, section header, option line — and a method line was not among them, though this section names it as one of the four recognisers. A guard is closed only over the shapes it can classify, so the two claims have to be checked against each other rather than assumed to agree. The classifier now knows method lines, which is what added the row above. In the same pass the scan stopped truncating at a file’s first #[cfg(test)] mod and began excising every test module instead: production code placed after one was silently unscanned, and html.rs and pack/validate.rs already carry a second test module.

proef’s own pack keys (macros:, match:, secret:, steps:, use:) are shaped like option lines and are excluded by name rather than listed as sanctioned rows: an inventory that is a third exceptions stops reading as a closed set.

The asymmetry this exposes

StepKindSpec::options exists, in its own words, as “the seam that keeps option spellings out of proef-core” — added because matching "retry-interval:" as a literal meant “one rule lived at two altitudes.” It covers recognising options. The core still writes retry:, retry-interval:, delay: and variable: as literals, so the same rule still lives at two altitudes, in the other direction.

Correction (2026-09-10) — the set was fourteen, and the fourteenth was unclassifiable

The measurement above says thirteen literals across four files. It was fourteen. The one it missed is "file," in emit.rs, where file_refs_in found the assets an artifact reads by scanning for that literal and a closing ; — hurl’s body grammar, in proef-core, for the entire life of asset staging.

It went unrecorded for the same reason the method line did, and this section had already written the rule that predicts it: a guard is closed only over the shapes it can classify. engine_grammar_kind knew fences, HTTP, [Section] headers, method lines and key: value options. A body constructor is none of those — no colon, no brackets, no uppercase — so the literal was never classified, never entered the inventory, and was never reported missing from it. The set was not measured and found closed; it was measured through a classifier that could not see this member.

That is the third decay of this section’s own claim: once by an order of magnitude in the count, once by a multi-line literal, and now by a shape. Each time the count was wrong in the direction of the guard’s blind spot, which is the only direction it can be wrong in.

Resolved by the second remedy, not the first. Decision 2 below sends the author to one of two options: widen the sanctioned set on the record, or put the syntax behind the seam. This is the first time the second was taken. The scan is now StepKindSpec::assets, a fourth engine-contributed hook beside validate, fragments and options — so the recognised group loses its body-reference member entirely rather than gaining a sanctioned row.

Moving it also fixed the reading. A text scan cannot tell a real file,…; body from the same six characters inside a JSON or assertion body; the engine reads its own AST and can. It also has to avoid hurl’s shared visit_filename hook, which carries the [Options] file paths (output, cacert, client-cert, client-key, netrc-file, unix-socket) alongside real bodies — output: names a file the run writes, and staging it would demand a source that cannot exist. Only the two body positions are read. None of that distinction is expressible in core, which is the argument for the seam stated as a capability rather than as a rule.

The classifier gained a body arm in the same change, so the blind spot is closed independently of the literal that exposed it: a bare lowercase keyword followed by a comma (file,, hex,, base64,) is now classified, and reintroducing one into core fails the guard with body "file," in emit.rs.

Decision

  1. The set above is the sanctioned core entry grammar. It is closed: a token outside it, or an existing token appearing in another core module, is a defect against this ADR.
  2. It is pinned by crates/proef-cli/tests/source_guards.rs (hurl_grammar_in_core_is_the_closed_set_the_adr_names), which lexes every production literal in proef-core and fails on growth, on relocation to another core module, and on shrinkage — then sends the author back here. A claim of this shape decays the moment it is only prose. This one already had, twice: once by an order of magnitude in the count, and once in this very section, which asserted a closed set while the guard behind it could not read a multi-line literal.
  3. Migrating the written group behind the seam (an emitter beside StepKindSpec::options) is deferred, not rejected. It buys nothing today: hurl is the only engine and no other is scheduled, so the migration would add a fn pointer, a trait obligation and a public-API break to relocate seven literals that exactly one implementation will ever supply. Trigger: a second engine being scheduled. That is also when ADR-0002’s acceptance test — a new engine lands with zero proef-core diff — first has anything to say about them; until then it is unfalsifiable here either way.

Consequences

The acceptance test is narrowed on the record: a second engine lands with zero proef-core diff except the written group, which is a known, enumerated, guarded debt with a named trigger rather than an open question. Anyone reaching for new hurl syntax in the core hits a failing test that names both remedies.