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-0005 — Two-tier variables (${…} / {{…}}), the World, and secrets

Status: Accepted · Date: 2026-07-28

Context

The author-time variable system (${var}, ${env:NAME:-default}, ${run:id}, ${global:key}, ${fake:*} seeded from the run id, $${} escape, recursive expansion) is proven with test authors. Hurl has its own runtime templating ({{name}}) fed by captures. The spike validated running both tiers side by side — including the bug it surfaced: step-captured args can themselves contain ${…} and need recursive (depth-capped) resolution.

Decision

Two explicit tiers. ${…} is author time: params, env (with defaults), run id, World reads (${global:key}), fake data, secrets references — resolved during lowering, recursively with a depth cap of 8, and baked into artifacts (except secrets). {{…}} is run time: hurl-native templates for captures, left verbatim in artifacts so the embedded engine and the stock CLI resolve them identically. World: one typed variable scope per scenario plus a persistent global store (.proef-state.json, atomic temp+rename). Engine bridging: seed hurl’s VariableSet from the World before each batch; merge HurlResult.variables back after; saveAs: global promotes a capture into the persistent store. Secrets: ${secret:NAME} resolves from the PROEF_SECRET_<NAME> environment override, else the encrypted store .proef-secrets.json (chacha20poly1305 + rpassword); values are injected via VariableSet::insert_secret (hurl redacts them in logs/reports); artifacts carry {{secret_name}} placeholders, never values; our reporters additionally redact by value (property-tested invariant).

Amendment (2026-08-16): “redact by value” includes each secret’s common encoded forms — base64 (both alphabets, with/without padding), hex (both cases), RFC 3986 percent-encoding, and the JSON-string escape — derived inside Redactions::new so every sink is covered by construction. Demonstrated live before the amendment: a server reflecting a bearer token base64-encoded put a trivially-decodable string into an assert-failure detail, the raw needle never fired, and the encoded credential reached the console and events.jsonl. The needle set covers the reversible transforms that occur at HTTP boundaries; a secret reflected hashed or re-encrypted matches no needle list, and the ADR does not claim otherwise. Over-redaction is the accepted failure direction.

Consequences

Artifacts are runnable by both toolchains with identical meaning; authors keep a familiar mental model unchanged; secrets are structurally absent from every persisted output. Cost: two syntaxes coexist in packs — mitigated by the strict rule of thumb (“$ = before the run, {{ = during the run”) documented in the pack authoring guide.

Alternatives considered

Single-tier (resolve everything at author time) — breaks capture chaining and makes artifacts non-parametric. Single-tier (everything hurl {{}}) — loses env defaults, fakes, and World reads. String-only World — kept typed here (hurl Value model) because captures cross engines; stringly-typed round-trips would lose numbers/bools at engine boundaries.

Errata

2026-09-07 (0.18): the invariant’s reach was found unenforced on one of its two paths. The event stream masks through Redactions::apply_event, an exhaustive destructure; the CI sinks that render from RunSummary (JUnit, CTRF, TAP, timings.json, the GitHub summary and annotations) masked failure detail, but five of them bypassed the masker for the identity fields (scenario, file, tags, the skip reason) — no live leak, since secrets lower to {{name}} and the engine pre-redacts details, but a boundary held by convention. Closed per sink (#171), then made structural (#178): Redactions::apply_outcome destructures ScenarioOutcome/StepOutcome without .., so a new text field fails to compile until it is masked, and each sink redacts one outcome after matching @quarantine on the raw identity. Per-sink rather than a wholesale apply_summary, because RunSummary also feeds exit_code_excluding, whose quarantine matching needs the unredacted identity.

2026-07-29 (v0.3.1): the “secrets reach no sink” invariant now explicitly covers the persistent World: a saveAs: global capture whose value equals a known secret is refused (the owning step warns) — .proef-state.json is plaintext at rest and must never receive secret-derived material. PROEF_KEY (base64) may supply the project key via the environment for CI use of a committed ciphertext store.

2026-07-28 (post-M5 hardening; API removed 2026-07-29 per YAGNI): “snapshot/restore across scenario retries” originally described a mechanism whose trigger was never specified anywhere in the corpus — no CLI flag, tag, or pack directive schedules a scenario-level retry (US-5’s step-level retry: is implemented and is the flake tool in practice). Decision: scenario-level retries are deferred indefinitely, and the unused GlobalStore::snapshot/restore API has been removed (YAGNI: no dead promised-behavior code). Whoever implements scenario retries specifies the trigger surface and its mechanism in a superseding ADR. Also note: the scenario merge-back is write-set-only (World tracks its saveAs promotions) — merging a whole snapshot back would lose concurrent scenarios’ updates.